浏览代码

Add SpringOpaqueTokenIntrospector

Closes gh-9354
Josh Cummings 4 年之前
父节点
当前提交
6370906ead

+ 3 - 3
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java

@@ -42,8 +42,8 @@ import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
 import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
-import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
 import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
+import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
 import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
@@ -454,7 +454,7 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 		public OpaqueTokenConfigurer introspectionUri(String introspectionUri) {
 			Assert.notNull(introspectionUri, "introspectionUri cannot be null");
 			this.introspectionUri = introspectionUri;
-			this.introspector = () -> new NimbusOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
+			this.introspector = () -> new SpringOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
 					this.clientSecret);
 			return this;
 		}
@@ -464,7 +464,7 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 			Assert.notNull(clientSecret, "clientSecret cannot be null");
 			this.clientId = clientId;
 			this.clientSecret = clientSecret;
-			this.introspector = () -> new NimbusOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
+			this.introspector = () -> new SpringOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
 					this.clientSecret);
 			return this;
 		}

+ 1 - 1
config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java

@@ -1124,7 +1124,7 @@ public class OAuth2ResourceServerConfigurerTests {
 		opaqueTokenConfigurer.introspector(client);
 		opaqueTokenConfigurer.introspectionUri(INTROSPECTION_URI);
 		opaqueTokenConfigurer.introspectionClientCredentials(CLIENT_ID, CLIENT_SECRET);
-		assertThat(opaqueTokenConfigurer.getIntrospector()).isInstanceOf(NimbusOpaqueTokenIntrospector.class);
+		assertThat(opaqueTokenConfigurer.getIntrospector()).isNotSameAs(client);
 	}
 
 	@Test

+ 216 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospector.java

@@ -0,0 +1,216 @@
+/*
+ * Copyright 2002-2021 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.server.resource.introspection;
+
+import java.net.URI;
+import java.net.URL;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.support.BasicAuthenticationInterceptor;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
+import org.springframework.util.Assert;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestOperations;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * A Spring implementation of {@link OpaqueTokenIntrospector} that verifies and
+ * introspects a token using the configured
+ * <a href="https://tools.ietf.org/html/rfc7662" target="_blank">OAuth 2.0 Introspection
+ * Endpoint</a>.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
+
+	private final Log logger = LogFactory.getLog(getClass());
+
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
+	};
+
+	private Converter<String, RequestEntity<?>> requestEntityConverter;
+
+	private RestOperations restOperations;
+
+	private final String authorityPrefix = "SCOPE_";
+
+	/**
+	 * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters
+	 * @param introspectionUri The introspection endpoint uri
+	 * @param clientId The client id authorized to introspect
+	 * @param clientSecret The client's secret
+	 */
+	public SpringOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) {
+		Assert.notNull(introspectionUri, "introspectionUri cannot be null");
+		Assert.notNull(clientId, "clientId cannot be null");
+		Assert.notNull(clientSecret, "clientSecret cannot be null");
+		this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri));
+		RestTemplate restTemplate = new RestTemplate();
+		restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret));
+		this.restOperations = restTemplate;
+	}
+
+	/**
+	 * Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters
+	 *
+	 * The given {@link RestOperations} should perform its own client authentication
+	 * against the introspection endpoint.
+	 * @param introspectionUri The introspection endpoint uri
+	 * @param restOperations The client for performing the introspection request
+	 */
+	public SpringOpaqueTokenIntrospector(String introspectionUri, RestOperations restOperations) {
+		Assert.notNull(introspectionUri, "introspectionUri cannot be null");
+		Assert.notNull(restOperations, "restOperations cannot be null");
+		this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri));
+		this.restOperations = restOperations;
+	}
+
+	private Converter<String, RequestEntity<?>> defaultRequestEntityConverter(URI introspectionUri) {
+		return (token) -> {
+			HttpHeaders headers = requestHeaders();
+			MultiValueMap<String, String> body = requestBody(token);
+			return new RequestEntity<>(body, headers, HttpMethod.POST, introspectionUri);
+		};
+	}
+
+	private HttpHeaders requestHeaders() {
+		HttpHeaders headers = new HttpHeaders();
+		headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
+		return headers;
+	}
+
+	private MultiValueMap<String, String> requestBody(String token) {
+		MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
+		body.add("token", token);
+		return body;
+	}
+
+	@Override
+	public OAuth2AuthenticatedPrincipal introspect(String token) {
+		RequestEntity<?> requestEntity = this.requestEntityConverter.convert(token);
+		if (requestEntity == null) {
+			throw new OAuth2IntrospectionException("requestEntityConverter returned a null entity");
+		}
+		ResponseEntity<Map<String, Object>> responseEntity = makeRequest(requestEntity);
+		Map<String, Object> claims = adaptToNimbusResponse(responseEntity);
+		return convertClaimsSet(claims);
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the OAuth 2.0 access token to a
+	 * {@link RequestEntity} representation of the OAuth 2.0 token introspection request.
+	 * @param requestEntityConverter the {@link Converter} used for converting to a
+	 * {@link RequestEntity} representation of the token introspection request
+	 */
+	public void setRequestEntityConverter(Converter<String, RequestEntity<?>> requestEntityConverter) {
+		Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null");
+		this.requestEntityConverter = requestEntityConverter;
+	}
+
+	private ResponseEntity<Map<String, Object>> makeRequest(RequestEntity<?> requestEntity) {
+		try {
+			return this.restOperations.exchange(requestEntity, STRING_OBJECT_MAP);
+		}
+		catch (Exception ex) {
+			throw new OAuth2IntrospectionException(ex.getMessage(), ex);
+		}
+	}
+
+	private Map<String, Object> adaptToNimbusResponse(ResponseEntity<Map<String, Object>> responseEntity) {
+		if (responseEntity.getStatusCode() != HttpStatus.OK) {
+			throw new OAuth2IntrospectionException(
+					"Introspection endpoint responded with " + responseEntity.getStatusCode());
+		}
+		Map<String, Object> claims = responseEntity.getBody();
+		// relying solely on the authorization server to validate this token (not checking
+		// 'exp', for example)
+		boolean active = (boolean) claims.compute(OAuth2IntrospectionClaimNames.ACTIVE, (k, v) -> {
+			if (v instanceof String) {
+				return Boolean.parseBoolean((String) v);
+			}
+			if (v instanceof Boolean) {
+				return v;
+			}
+			return false;
+		});
+		if (!active) {
+			this.logger.trace("Did not validate token since it is inactive");
+			throw new BadOpaqueTokenException("Provided token isn't active");
+		}
+		return claims;
+	}
+
+	private OAuth2AuthenticatedPrincipal convertClaimsSet(Map<String, Object> claims) {
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.AUDIENCE, (k, v) -> {
+			if (v instanceof String) {
+				return Collections.singletonList(v);
+			}
+			return v;
+		});
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.CLIENT_ID, (k, v) -> v.toString());
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.EXPIRES_AT,
+				(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUED_AT,
+				(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUER, (k, v) -> issuer(v.toString()));
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.NOT_BEFORE,
+				(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
+		Collection<GrantedAuthority> authorities = new ArrayList<>();
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.SCOPE, (k, v) -> {
+			if (v instanceof String) {
+				Collection<String> scopes = Arrays.asList(((String) v).split(" "));
+				for (String scope : scopes) {
+					authorities.add(new SimpleGrantedAuthority(this.authorityPrefix + scope));
+				}
+				return scopes;
+			}
+			return v;
+		});
+		return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities);
+	}
+
+	private URL issuer(String uri) {
+		try {
+			return new URL(uri);
+		}
+		catch (Exception ex) {
+			throw new OAuth2IntrospectionException(
+					"Invalid " + OAuth2IntrospectionClaimNames.ISSUER + " value: " + uri);
+		}
+	}
+
+}

+ 180 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospector.java

@@ -0,0 +1,180 @@
+/*
+ * Copyright 2002-2021 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.server.resource.introspection;
+
+import java.net.URI;
+import java.net.URL;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
+import org.springframework.util.Assert;
+import org.springframework.web.reactive.function.BodyInserters;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.WebClient;
+
+/**
+ * A Spring implementation of {@link ReactiveOpaqueTokenIntrospector} that verifies and
+ * introspects a token using the configured
+ * <a href="https://tools.ietf.org/html/rfc7662" target="_blank">OAuth 2.0 Introspection
+ * Endpoint</a>.
+ *
+ * @author Josh Cummings
+ * @since 5.6
+ */
+public class SpringReactiveOpaqueTokenIntrospector implements ReactiveOpaqueTokenIntrospector {
+
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
+	};
+
+	private final URI introspectionUri;
+
+	private final WebClient webClient;
+
+	private String authorityPrefix = "SCOPE_";
+
+	/**
+	 * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided
+	 * parameters
+	 * @param introspectionUri The introspection endpoint uri
+	 * @param clientId The client id authorized to introspect
+	 * @param clientSecret The client secret for the authorized client
+	 */
+	public SpringReactiveOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) {
+		Assert.hasText(introspectionUri, "introspectionUri cannot be empty");
+		Assert.hasText(clientId, "clientId cannot be empty");
+		Assert.notNull(clientSecret, "clientSecret cannot be null");
+		this.introspectionUri = URI.create(introspectionUri);
+		this.webClient = WebClient.builder().defaultHeaders((h) -> h.setBasicAuth(clientId, clientSecret)).build();
+	}
+
+	/**
+	 * Creates a {@code OpaqueTokenReactiveAuthenticationManager} with the provided
+	 * parameters
+	 * @param introspectionUri The introspection endpoint uri
+	 * @param webClient The client for performing the introspection request
+	 */
+	public SpringReactiveOpaqueTokenIntrospector(String introspectionUri, WebClient webClient) {
+		Assert.hasText(introspectionUri, "introspectionUri cannot be null");
+		Assert.notNull(webClient, "webClient cannot be null");
+		this.introspectionUri = URI.create(introspectionUri);
+		this.webClient = webClient;
+	}
+
+	@Override
+	public Mono<OAuth2AuthenticatedPrincipal> introspect(String token) {
+		// @formatter:off
+		return Mono.just(token)
+				.flatMap(this::makeRequest)
+				.flatMap(this::adaptToNimbusResponse)
+				.map(this::convertClaimsSet)
+				.onErrorMap((e) -> !(e instanceof OAuth2IntrospectionException), this::onError);
+		// @formatter:on
+	}
+
+	private Mono<ClientResponse> makeRequest(String token) {
+		// @formatter:off
+		return this.webClient.post()
+				.uri(this.introspectionUri)
+				.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+				.body(BodyInserters.fromFormData("token", token))
+				.exchange();
+		// @formatter:on
+	}
+
+	private Mono<Map<String, Object>> adaptToNimbusResponse(ClientResponse responseEntity) {
+		if (responseEntity.statusCode() != HttpStatus.OK) {
+			// @formatter:off
+			return responseEntity.bodyToFlux(DataBuffer.class)
+					.map(DataBufferUtils::release)
+					.then(Mono.error(new OAuth2IntrospectionException(
+							"Introspection endpoint responded with " + responseEntity.statusCode()))
+					);
+			// @formatter:on
+		}
+		// relying solely on the authorization server to validate this token (not checking
+		// 'exp', for example)
+		return responseEntity.bodyToMono(STRING_OBJECT_MAP)
+				.filter((body) -> (boolean) body.compute(OAuth2IntrospectionClaimNames.ACTIVE, (k, v) -> {
+					if (v instanceof String) {
+						return Boolean.parseBoolean((String) v);
+					}
+					if (v instanceof Boolean) {
+						return v;
+					}
+					return false;
+				})).switchIfEmpty(Mono.error(() -> new BadOpaqueTokenException("Provided token isn't active")));
+	}
+
+	private OAuth2AuthenticatedPrincipal convertClaimsSet(Map<String, Object> claims) {
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.AUDIENCE, (k, v) -> {
+			if (v instanceof String) {
+				return Collections.singletonList(v);
+			}
+			return v;
+		});
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.CLIENT_ID, (k, v) -> v.toString());
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.EXPIRES_AT,
+				(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUED_AT,
+				(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUER, (k, v) -> issuer(v.toString()));
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.NOT_BEFORE,
+				(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
+		Collection<GrantedAuthority> authorities = new ArrayList<>();
+		claims.computeIfPresent(OAuth2IntrospectionClaimNames.SCOPE, (k, v) -> {
+			if (v instanceof String) {
+				Collection<String> scopes = Arrays.asList(((String) v).split(" "));
+				for (String scope : scopes) {
+					authorities.add(new SimpleGrantedAuthority(this.authorityPrefix + scope));
+				}
+				return scopes;
+			}
+			return v;
+		});
+		return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities);
+	}
+
+	private URL issuer(String uri) {
+		try {
+			return new URL(uri);
+		}
+		catch (Exception ex) {
+			throw new OAuth2IntrospectionException(
+					"Invalid " + OAuth2IntrospectionClaimNames.ISSUER + " value: " + uri);
+		}
+	}
+
+	private OAuth2IntrospectionException onError(Throwable ex) {
+		return new OAuth2IntrospectionException(ex.getMessage(), ex);
+	}
+
+}

+ 368 - 0
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringOpaqueTokenIntrospectorTests.java

@@ -0,0 +1,368 @@
+/*
+ * Copyright 2002-2021 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.server.resource.introspection;
+
+import java.io.IOException;
+import java.net.URL;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import com.nimbusds.jose.util.JSONObjectUtils;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.RequestEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
+import org.springframework.web.client.RestOperations;
+
+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.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link SpringOpaqueTokenIntrospector}
+ */
+public class SpringOpaqueTokenIntrospectorTests {
+
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
+	};
+
+	private static final String INTROSPECTION_URL = "https://server.example.com";
+
+	private static final String CLIENT_ID = "client";
+
+	private static final String CLIENT_SECRET = "secret";
+
+	// @formatter:off
+	private static final String ACTIVE_RESPONSE = "{\n"
+			+ "      \"active\": true,\n"
+			+ "      \"client_id\": \"l238j323ds-23ij4\",\n"
+			+ "      \"username\": \"jdoe\",\n"
+			+ "      \"scope\": \"read write dolphin\",\n"
+			+ "      \"sub\": \"Z5O3upPC88QrAjx00dis\",\n"
+			+ "      \"aud\": \"https://protected.example.net/resource\",\n"
+			+ "      \"iss\": \"https://server.example.com/\",\n"
+			+ "      \"exp\": 1419356238,\n"
+			+ "      \"iat\": 1419350238,\n"
+			+ "      \"extension_field\": \"twenty-seven\"\n"
+			+ "     }";
+	// @formatter:on
+
+	// @formatter:off
+	private static final String INACTIVE_RESPONSE = "{\n"
+			+ "      \"active\": false\n"
+			+ "     }";
+	// @formatter:on
+
+	// @formatter:off
+	private static final String INVALID_RESPONSE = "{\n"
+			+ "      \"client_id\": \"l238j323ds-23ij4\",\n"
+			+ "      \"username\": \"jdoe\",\n"
+			+ "      \"scope\": \"read write dolphin\",\n"
+			+ "      \"sub\": \"Z5O3upPC88QrAjx00dis\",\n"
+			+ "      \"aud\": \"https://protected.example.net/resource\",\n"
+			+ "      \"iss\": \"https://server.example.com/\",\n"
+			+ "      \"exp\": 1419356238,\n"
+			+ "      \"iat\": 1419350238,\n"
+			+ "      \"extension_field\": \"twenty-seven\"\n"
+			+ "     }";
+	// @formatter:on
+
+	// @formatter:off
+	private static final String MALFORMED_ISSUER_RESPONSE = "{\n"
+			+ "     \"active\" : \"true\",\n"
+			+ "     \"iss\" : \"badissuer\"\n"
+			+ "    }";
+	// @formatter:on
+
+	// @formatter:off
+	private static final String MALFORMED_SCOPE_RESPONSE = "{\n"
+			+ "      \"active\": true,\n"
+			+ "      \"client_id\": \"l238j323ds-23ij4\",\n"
+			+ "      \"username\": \"jdoe\",\n"
+			+ "      \"scope\": [ \"read\", \"write\", \"dolphin\" ],\n"
+			+ "      \"sub\": \"Z5O3upPC88QrAjx00dis\",\n"
+			+ "      \"aud\": \"https://protected.example.net/resource\",\n"
+			+ "      \"iss\": \"https://server.example.com/\",\n"
+			+ "      \"exp\": 1419356238,\n"
+			+ "      \"iat\": 1419350238,\n"
+			+ "      \"extension_field\": \"twenty-seven\"\n"
+			+ "     }";
+	// @formatter:on
+
+	private static final ResponseEntity<Map<String, Object>> ACTIVE = response(ACTIVE_RESPONSE);
+
+	private static final ResponseEntity<Map<String, Object>> INACTIVE = response(INACTIVE_RESPONSE);
+
+	private static final ResponseEntity<Map<String, Object>> INVALID = response(INVALID_RESPONSE);
+
+	private static final ResponseEntity<Map<String, Object>> MALFORMED_ISSUER = response(MALFORMED_ISSUER_RESPONSE);
+
+	private static final ResponseEntity<Map<String, Object>> MALFORMED_SCOPE = response(MALFORMED_SCOPE_RESPONSE);
+
+	@Test
+	public void introspectWhenActiveTokenThenOk() throws Exception {
+		try (MockWebServer server = new MockWebServer()) {
+			server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE));
+			String introspectUri = server.url("/introspect").toString();
+			OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(introspectUri, CLIENT_ID,
+					CLIENT_SECRET);
+			OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token");
+			// @formatter:off
+			assertThat(authority.getAttributes())
+					.isNotNull()
+					.containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true)
+					.containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE,
+							Arrays.asList("https://protected.example.net/resource"))
+					.containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4")
+					.containsEntry(OAuth2IntrospectionClaimNames.EXPIRES_AT, Instant.ofEpochSecond(1419356238))
+					.containsEntry(OAuth2IntrospectionClaimNames.ISSUER, new URL("https://server.example.com/"))
+					.containsEntry(OAuth2IntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin"))
+					.containsEntry(OAuth2IntrospectionClaimNames.SUBJECT, "Z5O3upPC88QrAjx00dis")
+					.containsEntry(OAuth2IntrospectionClaimNames.USERNAME, "jdoe")
+					.containsEntry("extension_field", "twenty-seven");
+			// @formatter:on
+		}
+	}
+
+	@Test
+	public void introspectWhenBadClientCredentialsThenError() throws IOException {
+		try (MockWebServer server = new MockWebServer()) {
+			server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE));
+			String introspectUri = server.url("/introspect").toString();
+			OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(introspectUri, CLIENT_ID,
+					"wrong");
+			assertThatExceptionOfType(OAuth2IntrospectionException.class)
+					.isThrownBy(() -> introspectionClient.introspect("token"));
+		}
+	}
+
+	@Test
+	public void introspectWhenInactiveTokenThenInvalidToken() {
+		RestOperations restOperations = mock(RestOperations.class);
+		OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(INACTIVE);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token"))
+				.withMessage("Provided token isn't active");
+		// @formatter:on
+	}
+
+	@Test
+	public void introspectWhenActiveTokenThenParsesValuesInResponse() {
+		Map<String, Object> introspectedValues = new HashMap<>();
+		introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true);
+		introspectedValues.put(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud"));
+		introspectedValues.put(OAuth2IntrospectionClaimNames.NOT_BEFORE, 29348723984L);
+		RestOperations restOperations = mock(RestOperations.class);
+		OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP)))
+				.willReturn(response(introspectedValues));
+		OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token");
+		// @formatter:off
+		assertThat(authority.getAttributes())
+				.isNotNull()
+				.containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true)
+				.containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud"))
+				.containsEntry(OAuth2IntrospectionClaimNames.NOT_BEFORE, Instant.ofEpochSecond(29348723984L))
+				.doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID)
+				.doesNotContainKey(OAuth2IntrospectionClaimNames.SCOPE);
+		// @formatter:on
+	}
+
+	@Test
+	public void introspectWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() {
+		RestOperations restOperations = mock(RestOperations.class);
+		OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP)))
+				.willThrow(new IllegalStateException("server was unresponsive"));
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token"))
+				.withMessage("server was unresponsive");
+		// @formatter:on
+	}
+
+	@Test
+	public void introspectWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() {
+		RestOperations restOperations = mock(RestOperations.class);
+		OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(response("{}"));
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token"));
+	}
+
+	@Test
+	public void introspectWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() {
+		RestOperations restOperations = mock(RestOperations.class);
+		OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(INVALID);
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token"));
+	}
+
+	@Test
+	public void introspectWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() {
+		RestOperations restOperations = mock(RestOperations.class);
+		OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(MALFORMED_ISSUER);
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token"));
+	}
+
+	// gh-7563
+	@Test
+	public void introspectWhenIntrospectionTokenReturnsMalformedScopeThenEmptyAuthorities() {
+		RestOperations restOperations = mock(RestOperations.class);
+		OpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		given(restOperations.exchange(any(RequestEntity.class), eq(STRING_OBJECT_MAP))).willReturn(MALFORMED_SCOPE);
+		OAuth2AuthenticatedPrincipal principal = introspectionClient.introspect("token");
+		assertThat(principal.getAuthorities()).isEmpty();
+		Collection<String> scope = principal.getAttribute("scope");
+		assertThat(scope).containsExactly("read", "write", "dolphin");
+	}
+
+	@Test
+	public void constructorWhenIntrospectionUriIsNullThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringOpaqueTokenIntrospector(null, CLIENT_ID, CLIENT_SECRET));
+	}
+
+	@Test
+	public void constructorWhenClientIdIsNullThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, null, CLIENT_SECRET));
+	}
+
+	@Test
+	public void constructorWhenClientSecretIsNullThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null));
+	}
+
+	@Test
+	public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringOpaqueTokenIntrospector(INTROSPECTION_URL, null));
+	}
+
+	@Test
+	public void setRequestEntityConverterWhenConverterIsNullThenExceptionIsThrown() {
+		RestOperations restOperations = mock(RestOperations.class);
+		SpringOpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> introspectionClient.setRequestEntityConverter(null));
+	}
+
+	@SuppressWarnings("unchecked")
+	@Test
+	public void setRequestEntityConverterWhenNonNullConverterGivenThenConverterUsed() {
+		RestOperations restOperations = mock(RestOperations.class);
+		Converter<String, RequestEntity<?>> requestEntityConverter = mock(Converter.class);
+		RequestEntity requestEntity = mock(RequestEntity.class);
+		String tokenToIntrospect = "some token";
+		given(requestEntityConverter.convert(tokenToIntrospect)).willReturn(requestEntity);
+		given(restOperations.exchange(requestEntity, STRING_OBJECT_MAP)).willReturn(ACTIVE);
+		SpringOpaqueTokenIntrospector introspectionClient = new SpringOpaqueTokenIntrospector(INTROSPECTION_URL,
+				restOperations);
+		introspectionClient.setRequestEntityConverter(requestEntityConverter);
+		introspectionClient.introspect(tokenToIntrospect);
+		verify(requestEntityConverter).convert(tokenToIntrospect);
+	}
+
+	private static ResponseEntity<Map<String, Object>> response(String content) {
+		HttpHeaders headers = new HttpHeaders();
+		headers.setContentType(MediaType.APPLICATION_JSON);
+		try {
+			return new ResponseEntity<>(JSONObjectUtils.parse(content), headers, HttpStatus.OK);
+		}
+		catch (Exception ex) {
+			throw new IllegalArgumentException(ex);
+		}
+	}
+
+	private static ResponseEntity<Map<String, Object>> response(Map<String, Object> content) {
+		HttpHeaders headers = new HttpHeaders();
+		headers.setContentType(MediaType.APPLICATION_JSON);
+		try {
+			return new ResponseEntity<>(content, headers, HttpStatus.OK);
+		}
+		catch (Exception ex) {
+			throw new IllegalArgumentException(ex);
+		}
+	}
+
+	private static Dispatcher requiresAuth(String username, String password, String response) {
+		return new Dispatcher() {
+			@Override
+			public MockResponse dispatch(RecordedRequest request) {
+				String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
+				// @formatter:off
+				return Optional.ofNullable(authorization)
+						.filter((a) -> isAuthorized(authorization, username, password))
+						.map((a) -> ok(response))
+						.orElse(unauthorized());
+				// @formatter:on
+			}
+		};
+	}
+
+	private static boolean isAuthorized(String authorization, String username, String password) {
+		String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":");
+		return username.equals(values[0]) && password.equals(values[1]);
+	}
+
+	private static MockResponse ok(String response) {
+		// @formatter:off
+		return new MockResponse().setBody(response)
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+		// @formatter:on
+	}
+
+	private static MockResponse unauthorized() {
+		return new MockResponse().setResponseCode(401);
+	}
+
+}

+ 303 - 0
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/introspection/SpringReactiveOpaqueTokenIntrospectorTests.java

@@ -0,0 +1,303 @@
+/*
+ * Copyright 2002-2021 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.server.resource.introspection;
+
+import java.io.IOException;
+import java.net.URL;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.WebClient;
+
+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.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+/**
+ * Tests for {@link SpringReactiveOpaqueTokenIntrospector}
+ */
+public class SpringReactiveOpaqueTokenIntrospectorTests {
+
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
+	};
+
+	private static final String INTROSPECTION_URL = "https://server.example.com";
+
+	private static final String CLIENT_ID = "client";
+
+	private static final String CLIENT_SECRET = "secret";
+
+	// @formatter:off
+	private static final String ACTIVE_RESPONSE = "{\n"
+			+ "      \"active\": true,\n"
+			+ "      \"client_id\": \"l238j323ds-23ij4\",\n"
+			+ "      \"username\": \"jdoe\",\n"
+			+ "      \"scope\": \"read write dolphin\",\n"
+			+ "      \"sub\": \"Z5O3upPC88QrAjx00dis\",\n"
+			+ "      \"aud\": \"https://protected.example.net/resource\",\n"
+			+ "      \"iss\": \"https://server.example.com/\",\n"
+			+ "      \"exp\": 1419356238,\n"
+			+ "      \"iat\": 1419350238,\n"
+			+ "      \"extension_field\": \"twenty-seven\"\n"
+			+ "     }";
+	// @formatter:on
+
+	// @formatter:off
+	private static final String INACTIVE_RESPONSE = "{\n"
+			+ "      \"active\": false\n"
+			+ "     }";
+	// @formatter:on
+
+	// @formatter:off
+	private static final String INVALID_RESPONSE = "{\n"
+			+ "      \"client_id\": \"l238j323ds-23ij4\",\n"
+			+ "      \"username\": \"jdoe\",\n"
+			+ "      \"scope\": \"read write dolphin\",\n"
+			+ "      \"sub\": \"Z5O3upPC88QrAjx00dis\",\n"
+			+ "      \"aud\": \"https://protected.example.net/resource\",\n"
+			+ "      \"iss\": \"https://server.example.com/\",\n"
+			+ "      \"exp\": 1419356238,\n"
+			+ "      \"iat\": 1419350238,\n"
+			+ "      \"extension_field\": \"twenty-seven\"\n"
+			+ "     }";
+	// @formatter:on
+
+	// @formatter:off
+	private static final String MALFORMED_ISSUER_RESPONSE = "{\n"
+			+ "     \"active\" : \"true\",\n"
+			+ "     \"iss\" : \"badissuer\"\n"
+			+ "    }";
+	// @formatter:on
+
+	private final ObjectMapper mapper = new ObjectMapper();
+
+	@Test
+	public void authenticateWhenActiveTokenThenOk() throws Exception {
+		try (MockWebServer server = new MockWebServer()) {
+			server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE));
+			String introspectUri = server.url("/introspect").toString();
+			SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector(
+					introspectUri, CLIENT_ID, CLIENT_SECRET);
+			OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block();
+			// @formatter:off
+			assertThat(authority.getAttributes())
+					.isNotNull()
+					.containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true)
+					.containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE,
+							Arrays.asList("https://protected.example.net/resource"))
+					.containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4")
+					.containsEntry(OAuth2IntrospectionClaimNames.EXPIRES_AT, Instant.ofEpochSecond(1419356238))
+					.containsEntry(OAuth2IntrospectionClaimNames.ISSUER, new URL("https://server.example.com/"))
+					.containsEntry(OAuth2IntrospectionClaimNames.SCOPE, Arrays.asList("read", "write", "dolphin"))
+					.containsEntry(OAuth2IntrospectionClaimNames.SUBJECT, "Z5O3upPC88QrAjx00dis")
+					.containsEntry(OAuth2IntrospectionClaimNames.USERNAME, "jdoe")
+					.containsEntry("extension_field", "twenty-seven");
+			// @formatter:on
+		}
+	}
+
+	@Test
+	public void authenticateWhenBadClientCredentialsThenAuthenticationException() throws IOException {
+		try (MockWebServer server = new MockWebServer()) {
+			server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE));
+			String introspectUri = server.url("/introspect").toString();
+			SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector(
+					introspectUri, CLIENT_ID, "wrong");
+			assertThatExceptionOfType(OAuth2IntrospectionException.class)
+					.isThrownBy(() -> introspectionClient.introspect("token").block());
+
+		}
+	}
+
+	@Test
+	public void authenticateWhenInactiveTokenThenInvalidToken() {
+		WebClient webClient = mockResponse(INACTIVE_RESPONSE);
+		SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector(
+				INTROSPECTION_URL, webClient);
+		assertThatExceptionOfType(BadOpaqueTokenException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token").block())
+				.withMessage("Provided token isn't active");
+	}
+
+	@Test
+	public void authenticateWhenActiveTokenThenParsesValuesInResponse() {
+		Map<String, Object> introspectedValues = new HashMap<>();
+		introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true);
+		introspectedValues.put(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud"));
+		introspectedValues.put(OAuth2IntrospectionClaimNames.NOT_BEFORE, 29348723984L);
+		WebClient webClient = mockResponse(introspectedValues);
+		SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector(
+				INTROSPECTION_URL, webClient);
+		OAuth2AuthenticatedPrincipal authority = introspectionClient.introspect("token").block();
+		// @formatter:off
+		assertThat(authority.getAttributes())
+				.isNotNull()
+				.containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true)
+				.containsEntry(OAuth2IntrospectionClaimNames.AUDIENCE, Arrays.asList("aud"))
+				.containsEntry(OAuth2IntrospectionClaimNames.NOT_BEFORE, Instant.ofEpochSecond(29348723984L))
+				.doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID)
+				.doesNotContainKey(OAuth2IntrospectionClaimNames.SCOPE);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() {
+		WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive"));
+		SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector(
+				INTROSPECTION_URL, webClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token").block())
+				.withMessage("server was unresponsive");
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() {
+		WebClient webClient = mockResponse(INVALID_RESPONSE);
+		SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector(
+				INTROSPECTION_URL, webClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token").block());
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() {
+		WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE);
+		SpringReactiveOpaqueTokenIntrospector introspectionClient = new SpringReactiveOpaqueTokenIntrospector(
+				INTROSPECTION_URL, webClient);
+		assertThatExceptionOfType(OAuth2IntrospectionException.class)
+				.isThrownBy(() -> introspectionClient.introspect("token").block());
+	}
+
+	@Test
+	public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector("", CLIENT_ID, CLIENT_SECRET));
+	}
+
+	@Test
+	public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, "", CLIENT_SECRET));
+	}
+
+	@Test
+	public void constructorWhenClientSecretIsNullThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, CLIENT_ID, null));
+	}
+
+	@Test
+	public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new SpringReactiveOpaqueTokenIntrospector(INTROSPECTION_URL, null));
+	}
+
+	private WebClient mockResponse(String response) {
+		return mockResponse(toMap(response));
+	}
+
+	private WebClient mockResponse(Map<String, Object> response) {
+		WebClient real = WebClient.builder().build();
+		WebClient.RequestBodyUriSpec spec = spy(real.post());
+		WebClient webClient = spy(WebClient.class);
+		given(webClient.post()).willReturn(spec);
+		ClientResponse clientResponse = mock(ClientResponse.class);
+		given(clientResponse.rawStatusCode()).willReturn(200);
+		given(clientResponse.statusCode()).willReturn(HttpStatus.OK);
+		given(clientResponse.bodyToMono(STRING_OBJECT_MAP)).willReturn(Mono.just(response));
+		ClientResponse.Headers headers = mock(ClientResponse.Headers.class);
+		given(headers.contentType()).willReturn(Optional.of(MediaType.APPLICATION_JSON));
+		given(clientResponse.headers()).willReturn(headers);
+		given(spec.exchange()).willReturn(Mono.just(clientResponse));
+		return webClient;
+	}
+
+	private Map<String, Object> toMap(String string) {
+		try {
+			return this.mapper.readValue(string, Map.class);
+		}
+		catch (Exception ex) {
+			throw new IllegalArgumentException(ex);
+		}
+	}
+
+	private WebClient mockResponse(Throwable ex) {
+		WebClient real = WebClient.builder().build();
+		WebClient.RequestBodyUriSpec spec = spy(real.post());
+		WebClient webClient = spy(WebClient.class);
+		given(webClient.post()).willReturn(spec);
+		given(spec.exchange()).willThrow(ex);
+		return webClient;
+	}
+
+	private static Dispatcher requiresAuth(String username, String password, String response) {
+		return new Dispatcher() {
+			@Override
+			public MockResponse dispatch(RecordedRequest request) {
+				String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
+				// @formatter:off
+				return Optional.ofNullable(authorization)
+						.filter((a) -> isAuthorized(authorization, username, password))
+						.map((a) -> ok(response))
+						.orElse(unauthorized());
+				// @formatter:on
+			}
+		};
+	}
+
+	private static boolean isAuthorized(String authorization, String username, String password) {
+		String[] values = new String(Base64.getDecoder().decode(authorization.substring(6))).split(":");
+		return username.equals(values[0]) && password.equals(values[1]);
+	}
+
+	private static MockResponse ok(String response) {
+		// @formatter:off
+		return new MockResponse().setBody(response)
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+		// @formatter:on
+	}
+
+	private static MockResponse unauthorized() {
+		return new MockResponse().setResponseCode(401);
+	}
+
+}