Переглянути джерело

Reactive Opaque Token Support

Fixes: gh-6513
Josh Cummings 6 роки тому
батько
коміт
fba25614bf

+ 155 - 72
config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

@@ -16,6 +16,24 @@
 
 package org.springframework.security.config.web.server;
 
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.security.interfaces.RSAPublicKey;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+
+import reactor.core.publisher.Mono;
+import reactor.util.context.Context;
+
 import org.springframework.beans.BeansException;
 import org.springframework.context.ApplicationContext;
 import org.springframework.core.Ordered;
@@ -65,6 +83,7 @@ import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder;
 import org.springframework.security.oauth2.jwt.ReactiveJwtDecoderFactory;
 import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
 import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager;
+import org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionReactiveAuthenticationManager;
 import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
 import org.springframework.security.oauth2.server.resource.web.access.server.BearerTokenServerAccessDeniedHandler;
 import org.springframework.security.oauth2.server.resource.web.server.BearerTokenServerAuthenticationEntryPoint;
@@ -143,23 +162,6 @@ import org.springframework.web.cors.reactive.DefaultCorsProcessor;
 import org.springframework.web.server.ServerWebExchange;
 import org.springframework.web.server.WebFilter;
 import org.springframework.web.server.WebFilterChain;
-import reactor.core.publisher.Mono;
-import reactor.util.context.Context;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-import java.security.interfaces.RSAPublicKey;
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.UUID;
-import java.util.function.Function;
 
 import static org.springframework.security.web.server.DelegatingServerAuthenticationEntryPoint.DelegateEntry;
 import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher.MatchResult.match;
@@ -994,8 +996,11 @@ public class ServerHttpSecurity {
 		private ServerAuthenticationEntryPoint entryPoint = new BearerTokenServerAuthenticationEntryPoint();
 		private ServerAccessDeniedHandler accessDeniedHandler = new BearerTokenServerAccessDeniedHandler();
 		private ServerAuthenticationConverter bearerTokenConverter = new ServerBearerTokenAuthenticationConverter();
+		private BearerTokenServerWebExchangeMatcher bearerTokenServerWebExchangeMatcher =
+				new BearerTokenServerWebExchangeMatcher();
 
 		private JwtSpec jwt;
+		private OpaqueTokenSpec opaqueToken;
 
 		/**
 		 * Configures the {@link ServerAccessDeniedHandler} to use for requests authenticating with
@@ -1047,10 +1052,94 @@ public class ServerHttpSecurity {
 			return this.jwt;
 		}
 
+		public OpaqueTokenSpec opaqueToken() {
+			if (this.opaqueToken == null) {
+				this.opaqueToken = new OpaqueTokenSpec();
+			}
+			return this.opaqueToken;
+		}
+
 		protected void configure(ServerHttpSecurity http) {
+			this.bearerTokenServerWebExchangeMatcher
+					.setBearerTokenConverter(this.bearerTokenConverter);
+
+			registerDefaultAccessDeniedHandler(http);
+			registerDefaultAuthenticationEntryPoint(http);
+			registerDefaultCsrfOverride(http);
+
+			if (this.jwt != null && this.opaqueToken != null) {
+				throw new IllegalStateException("Spring Security only supports JWTs or Opaque Tokens, not both at the " +
+						"same time");
+			}
+
+			if (this.jwt == null && this.opaqueToken == null) {
+				throw new IllegalStateException("Jwt and Opaque Token are the only supported formats for bearer tokens " +
+						"in Spring Security and neither was found. Make sure to configure JWT " +
+						"via http.oauth2ResourceServer().jwt() or Opaque Tokens via " +
+						"http.oauth2ResourceServer().opaqueToken().");
+			}
+
 			if (this.jwt != null) {
 				this.jwt.configure(http);
 			}
+
+			if (this.opaqueToken != null) {
+				this.opaqueToken.configure(http);
+			}
+		}
+
+		private void registerDefaultAccessDeniedHandler(ServerHttpSecurity http) {
+			if ( http.exceptionHandling != null ) {
+				http.defaultAccessDeniedHandlers.add(
+						new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry(
+								this.bearerTokenServerWebExchangeMatcher,
+								OAuth2ResourceServerSpec.this.accessDeniedHandler
+						)
+				);
+			}
+		}
+
+		private void registerDefaultAuthenticationEntryPoint(ServerHttpSecurity http) {
+			if (http.exceptionHandling != null) {
+				http.defaultEntryPoints.add(
+						new DelegateEntry(
+								this.bearerTokenServerWebExchangeMatcher,
+								OAuth2ResourceServerSpec.this.entryPoint
+						)
+				);
+			}
+		}
+
+		private void registerDefaultCsrfOverride(ServerHttpSecurity http) {
+			if ( http.csrf != null && !http.csrf.specifiedRequireCsrfProtectionMatcher ) {
+				http
+					.csrf()
+					.requireCsrfProtectionMatcher(
+							new AndServerWebExchangeMatcher(
+									CsrfWebFilter.DEFAULT_CSRF_MATCHER,
+									new NegatedServerWebExchangeMatcher(
+											this.bearerTokenServerWebExchangeMatcher)));
+			}
+		}
+
+		private class BearerTokenServerWebExchangeMatcher implements ServerWebExchangeMatcher {
+			ServerAuthenticationConverter bearerTokenConverter;
+
+			@Override
+			public Mono<MatchResult> matches(ServerWebExchange exchange) {
+				return this.bearerTokenConverter.convert(exchange)
+						.flatMap(this::nullAuthentication)
+						.onErrorResume(e -> notMatch());
+			}
+
+			public void setBearerTokenConverter(ServerAuthenticationConverter bearerTokenConverter) {
+				Assert.notNull(bearerTokenConverter, "bearerTokenConverter cannot be null");
+				this.bearerTokenConverter = bearerTokenConverter;
+			}
+
+			private Mono<MatchResult> nullAuthentication(Authentication authentication) {
+				return authentication == null ? notMatch() : match();
+			}
 		}
 
 		/**
@@ -1062,9 +1151,6 @@ public class ServerHttpSecurity {
 			private Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter
 					= new ReactiveJwtAuthenticationConverterAdapter(new JwtAuthenticationConverter());
 
-			private BearerTokenServerWebExchangeMatcher bearerTokenServerWebExchangeMatcher =
-					new BearerTokenServerWebExchangeMatcher();
-
 			/**
 			 * Configures the {@link ReactiveAuthenticationManager} to use
 			 * @param authenticationManager the authentication manager to use
@@ -1128,17 +1214,10 @@ public class ServerHttpSecurity {
 			}
 
 			protected void configure(ServerHttpSecurity http) {
-				this.bearerTokenServerWebExchangeMatcher.setBearerTokenConverter(bearerTokenConverter);
-
-				registerDefaultAccessDeniedHandler(http);
-				registerDefaultAuthenticationEntryPoint(http);
-				registerDefaultCsrfOverride(http);
-
 				ReactiveAuthenticationManager authenticationManager = getAuthenticationManager();
 				AuthenticationWebFilter oauth2 = new AuthenticationWebFilter(authenticationManager);
 				oauth2.setServerAuthenticationConverter(bearerTokenConverter);
 				oauth2.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler(entryPoint));
-
 				http
 					.addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION);
 			}
@@ -1170,59 +1249,63 @@ public class ServerHttpSecurity {
 
 				return authenticationManager;
 			}
+		}
 
-			private void registerDefaultAccessDeniedHandler(ServerHttpSecurity http) {
-				if ( http.exceptionHandling != null ) {
-					http.defaultAccessDeniedHandlers.add(
-							new ServerWebExchangeDelegatingServerAccessDeniedHandler.DelegateEntry(
-									this.bearerTokenServerWebExchangeMatcher,
-									OAuth2ResourceServerSpec.this.accessDeniedHandler
-							)
-					);
-				}
-			}
+		/**
+		 * Configures Opaque Token Resource Server support
+		 *
+		 * @author Josh Cummings
+		 * @since 5.2
+		 */
+		public class OpaqueTokenSpec {
+			private String introspectionUri;
+			private String introspectionClientId;
+			private String introspectionClientSecret;
 
-			private void registerDefaultAuthenticationEntryPoint(ServerHttpSecurity http) {
-				if (http.exceptionHandling != null) {
-					http.defaultEntryPoints.add(
-							new DelegateEntry(
-									this.bearerTokenServerWebExchangeMatcher,
-									OAuth2ResourceServerSpec.this.entryPoint
-							)
-					);
-				}
+			/**
+			 * Configures the URI of the Introspection endpoint
+			 * @param introspectionUri The URI of the Introspection endpoint
+			 * @return the {@code OpaqueTokenSpec} for additional configuration
+			 */
+			public OpaqueTokenSpec introspectionUri(String introspectionUri) {
+				Assert.hasText(introspectionUri, "introspectionUri cannot be empty");
+				this.introspectionUri = introspectionUri;
+				return this;
 			}
 
-			private void registerDefaultCsrfOverride(ServerHttpSecurity http) {
-				if ( http.csrf != null && !http.csrf.specifiedRequireCsrfProtectionMatcher ) {
-					http
-						.csrf()
-							.requireCsrfProtectionMatcher(
-									new AndServerWebExchangeMatcher(
-											CsrfWebFilter.DEFAULT_CSRF_MATCHER,
-											new NegatedServerWebExchangeMatcher(
-													this.bearerTokenServerWebExchangeMatcher)));
-				}
+			/**
+			 * Configures the credentials for Introspection endpoint
+			 * @param clientId The clientId part of the credentials
+			 * @param clientSecret The clientSecret part of the credentials
+			 * @return the {@code OpaqueTokenSpec} for additional configuration
+			 */
+			public OpaqueTokenSpec introspectionClientCredentials(String clientId, String clientSecret) {
+				Assert.hasText(clientId, "clientId cannot be empty");
+				Assert.notNull(clientSecret, "clientSecret cannot be null");
+				this.introspectionClientId = clientId;
+				this.introspectionClientSecret = clientSecret;
+				return this;
 			}
 
-			private class BearerTokenServerWebExchangeMatcher implements ServerWebExchangeMatcher {
-				ServerAuthenticationConverter bearerTokenConverter;
-
-				@Override
-				public Mono<MatchResult> matches(ServerWebExchange exchange) {
-					return this.bearerTokenConverter.convert(exchange)
-							.flatMap(this::nullAuthentication)
-							.onErrorResume(e -> notMatch());
-				}
+			/**
+			 * Allows method chaining to continue configuring the {@link ServerHttpSecurity}
+			 * @return the {@link ServerHttpSecurity} to continue configuring
+			 */
+			public OAuth2ResourceServerSpec and() {
+				return OAuth2ResourceServerSpec.this;
+			}
 
-				public void setBearerTokenConverter(ServerAuthenticationConverter bearerTokenConverter) {
-					Assert.notNull(bearerTokenConverter, "bearerTokenConverter cannot be null");
-					this.bearerTokenConverter = bearerTokenConverter;
-				}
+			protected ReactiveAuthenticationManager getAuthenticationManager() {
+				return new OAuth2IntrospectionReactiveAuthenticationManager(
+						this.introspectionUri, this.introspectionClientId, this.introspectionClientSecret);
+			}
 
-				private Mono<MatchResult> nullAuthentication(Authentication authentication) {
-					return authentication == null ? notMatch() : match();
-				}
+			protected void configure(ServerHttpSecurity http) {
+				ReactiveAuthenticationManager authenticationManager = getAuthenticationManager();
+				AuthenticationWebFilter oauth2 = new AuthenticationWebFilter(authenticationManager);
+				oauth2.setServerAuthenticationConverter(bearerTokenConverter);
+				oauth2.setAuthenticationFailureHandler(new ServerAuthenticationEntryPointFailureHandler(entryPoint));
+				http.addFilterAt(oauth2, SecurityWebFiltersOrder.AUTHENTICATION);
 			}
 		}
 

+ 103 - 14
config/src/test/java/org/springframework/security/config/web/server/OAuth2ResourceServerSpecTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2018 the original author or authors.
+ * Copyright 2002-2019 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.
@@ -13,16 +13,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.springframework.security.config.web.server;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
-import static org.hamcrest.core.StringStartsWith.startsWith;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+package org.springframework.security.config.web.server;
 
 import java.io.IOException;
 import java.math.BigInteger;
@@ -32,18 +24,22 @@ import java.security.interfaces.RSAPublicKey;
 import java.security.spec.InvalidKeySpecException;
 import java.security.spec.RSAPublicKeySpec;
 import java.time.Instant;
+import java.util.Base64;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Optional;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
-
 import javax.annotation.PreDestroy;
 
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
 import org.apache.http.HttpHeaders;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
-
 import reactor.core.publisher.Mono;
 
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
@@ -53,6 +49,7 @@ import org.springframework.context.ApplicationContext;
 import org.springframework.context.annotation.Bean;
 import org.springframework.core.convert.converter.Converter;
 import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
 import org.springframework.security.authentication.AbstractAuthenticationToken;
 import org.springframework.security.authentication.ReactiveAuthenticationManager;
 import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
@@ -81,8 +78,14 @@ import org.springframework.web.context.support.GenericWebApplicationContext;
 import org.springframework.web.reactive.DispatcherHandler;
 import org.springframework.web.reactive.config.EnableWebFlux;
 
-import okhttp3.mockwebserver.MockResponse;
-import okhttp3.mockwebserver.MockWebServer;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.hamcrest.core.StringStartsWith.startsWith;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
 
 /**
  * Tests for {@link org.springframework.security.config.web.server.ServerHttpSecurity.OAuth2ResourceServerSpec}
@@ -113,6 +116,22 @@ public class OAuth2ResourceServerSpecTests {
 			Collections.singletonMap("alg", JwsAlgorithms.RS256),
 			Collections.singletonMap("sub", "user"));
 
+	private String clientId = "client";
+	private String clientSecret = "secret";
+	private String active = "{\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" +
+			"     }";
+
+
 	@Rule
 	public final SpringTestRule spring = new SpringTestRule();
 
@@ -332,6 +351,18 @@ public class OAuth2ResourceServerSpecTests {
 				.isInstanceOf(NoSuchBeanDefinitionException.class);
 	}
 
+	@Test
+	public void introspectWhenValidThenReturnsOk() {
+		this.spring.register(IntrospectionConfig.class, RootController.class).autowire();
+		this.spring.getContext().getBean(MockWebServer.class)
+				.setDispatcher(requiresAuth(clientId, clientSecret, active));
+
+		this.client.get()
+				.headers(headers -> headers.setBearerAuth(this.messageReadToken))
+				.exchange()
+				.expectStatus().isOk();
+	}
+
 	@EnableWebFlux
 	@EnableWebFluxSecurity
 	static class PublicKeyConfig {
@@ -525,6 +556,37 @@ public class OAuth2ResourceServerSpecTests {
 		}
 	}
 
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	static class IntrospectionConfig {
+		private MockWebServer mockWebServer = new MockWebServer();
+
+		@Bean
+		SecurityWebFilterChain springSecurity(ServerHttpSecurity http) {
+			String introspectionUri = mockWebServer().url("/introspect").toString();
+
+			// @formatter:off
+			http
+				.oauth2ResourceServer()
+					.opaqueToken()
+						.introspectionUri(introspectionUri)
+						.introspectionClientCredentials("client", "secret");
+			// @formatter:on
+
+			return http.build();
+		}
+
+		@Bean
+		MockWebServer mockWebServer() {
+			return this.mockWebServer;
+		}
+
+		@PreDestroy
+		void shutdown() throws IOException {
+			this.mockWebServer.shutdown();
+		}
+	}
+
 	@RestController
 	static class RootController {
 		@GetMapping
@@ -538,6 +600,33 @@ public class OAuth2ResourceServerSpecTests {
 		}
 	}
 
+	private static Dispatcher requiresAuth(String username, String password, String response) {
+		return new Dispatcher() {
+			@Override
+			public MockResponse dispatch(RecordedRequest request) {
+				String authorization = request.getHeader(org.springframework.http.HttpHeaders.AUTHORIZATION);
+				return Optional.ofNullable(authorization)
+						.filter(a -> isAuthorized(authorization, username, password))
+						.map(a -> ok(response))
+						.orElse(unauthorized());
+			}
+		};
+	}
+
+	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) {
+		return new MockResponse().setBody(response)
+				.setHeader(org.springframework.http.HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+	}
+
+	private static MockResponse unauthorized() {
+		return new MockResponse().setResponseCode(401);
+	}
+
 	private static RSAPublicKey publicKey() throws NoSuchAlgorithmException, InvalidKeySpecException {
 		String modulus = "26323220897278656456354815752829448539647589990395639665273015355787577386000316054335559633864476469390247312823732994485311378484154955583861993455004584140858982659817218753831620205191028763754231454775026027780771426040997832758235764611119743390612035457533732596799927628476322029280486807310749948064176545712270582940917249337311592011920620009965129181413510845780806191965771671528886508636605814099711121026468495328702234901200169245493126030184941412539949521815665744267183140084667383643755535107759061065656273783542590997725982989978433493861515415520051342321336460543070448417126615154138673620797";
 		String exponent = "65537";

+ 270 - 0
oauth2/oauth2-resource-server/src/main/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManager.java

@@ -0,0 +1,270 @@
+/*
+ * Copyright 2002-2019 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
+ *
+ *      http://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.authentication;
+
+import java.net.URI;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import com.nimbusds.oauth2.sdk.TokenIntrospectionResponse;
+import com.nimbusds.oauth2.sdk.TokenIntrospectionSuccessResponse;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+import com.nimbusds.oauth2.sdk.id.Audience;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
+import org.springframework.security.oauth2.server.resource.BearerTokenError;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+import org.springframework.web.reactive.function.BodyInserters;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.WebClient;
+
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.CLIENT_ID;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUED_AT;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE;
+
+/**
+ * An {@link ReactiveAuthenticationManager} implementation for opaque
+ * <a href="https://tools.ietf.org/html/rfc6750#section-1.2" target="_blank">Bearer Token</a>s,
+ * using an
+ * <a href="https://tools.ietf.org/html/rfc7662" target="_blank">OAuth 2.0 Introspection Endpoint</a>
+ * to check the token's validity and reveal its attributes.
+ * <p>
+ * This {@link ReactiveAuthenticationManager} is responsible for introspecting and verifying an opaque access token,
+ * returning its attributes set as part of the {@see Authentication} statement.
+ * <p>
+ * Scopes are translated into {@link GrantedAuthority}s according to the following algorithm:
+ * <ol>
+ * <li>
+ * If there is a "scope" attribute, then convert to a {@link Collection} of {@link String}s.
+ * <li>
+ * Take the resulting {@link Collection} and prepend the "SCOPE_" keyword to each element, adding as {@link GrantedAuthority}s.
+ * </ol>
+ *
+ * @author Josh Cummings
+ * @since 5.2
+ * @see ReactiveAuthenticationManager
+ */
+public class OAuth2IntrospectionReactiveAuthenticationManager implements ReactiveAuthenticationManager {
+	private URI introspectionUri;
+	private WebClient webClient;
+
+	/**
+	 * Creates a {@code OAuth2IntrospectionReactiveAuthenticationManager} 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 OAuth2IntrospectionReactiveAuthenticationManager(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()
+				.defaultHeader(HttpHeaders.AUTHORIZATION, basicHeaderValue(clientId, clientSecret))
+				.build();
+	}
+
+	/**
+	 * Creates a {@code OAuth2IntrospectionReactiveAuthenticationManager} with the provided parameters
+	 *
+	 * @param introspectionUri The introspection endpoint uri
+	 * @param webClient The client for performing the introspection request
+	 */
+	public OAuth2IntrospectionReactiveAuthenticationManager(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;
+	}
+
+	private static String basicHeaderValue(String clientId, String clientSecret) {
+		String headerValue = clientId + ":";
+		if (StringUtils.hasText(clientSecret)) {
+			headerValue += clientSecret;
+		}
+		return "Basic " + Base64.getEncoder().encodeToString(headerValue.getBytes(StandardCharsets.UTF_8));
+	}
+
+	@Override
+	public Mono<Authentication> authenticate(Authentication authentication) {
+		return Mono.justOrEmpty(authentication)
+				.filter(BearerTokenAuthenticationToken.class::isInstance)
+				.cast(BearerTokenAuthenticationToken.class)
+				.map(BearerTokenAuthenticationToken::getToken)
+				.flatMap(this::authenticate)
+				.cast(Authentication.class);
+	}
+
+	private Mono<OAuth2IntrospectionAuthenticationToken> authenticate(String token) {
+		return introspect(token)
+				.map(response -> {
+					Map<String, Object> claims = convertClaimsSet(response);
+					Instant iat = (Instant) claims.get(ISSUED_AT);
+					Instant exp = (Instant) claims.get(EXPIRES_AT);
+
+					// construct token
+					OAuth2AccessToken accessToken =
+							new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, token, iat, exp);
+					Collection<GrantedAuthority> authorities = extractAuthorities(claims);
+					return new OAuth2IntrospectionAuthenticationToken(accessToken, claims, authorities);
+				});
+	}
+
+	private Mono<TokenIntrospectionSuccessResponse> introspect(String token) {
+		return Mono.just(token)
+				.flatMap(this::makeRequest)
+				.flatMap(this::adaptToNimbusResponse)
+				.map(this::parseNimbusResponse)
+				.map(this::castToNimbusSuccess)
+				.doOnNext(response -> validate(token, response))
+				.onErrorMap(e -> !(e instanceof OAuth2AuthenticationException), this::onError);
+	}
+
+	private Mono<ClientResponse> makeRequest(String token) {
+		return this.webClient.post()
+				.uri(this.introspectionUri)
+				.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_UTF8_VALUE)
+				.body(BodyInserters.fromFormData("token", token))
+				.exchange();
+	}
+
+	private Mono<HTTPResponse> adaptToNimbusResponse(ClientResponse responseEntity) {
+		HTTPResponse response = new HTTPResponse(responseEntity.rawStatusCode());
+		response.setHeader(HttpHeaders.CONTENT_TYPE, responseEntity.headers().contentType().get().toString());
+		if (response.getStatusCode() != HTTPResponse.SC_OK) {
+			throw new OAuth2AuthenticationException(
+					invalidToken("Introspection endpoint responded with " + response.getStatusCode()));
+		}
+		return responseEntity.bodyToMono(String.class)
+				.doOnNext(response::setContent)
+				.map(body -> response);
+	}
+
+	private TokenIntrospectionResponse parseNimbusResponse(HTTPResponse response) {
+		try {
+			return TokenIntrospectionResponse.parse(response);
+		} catch (Exception ex) {
+			throw new OAuth2AuthenticationException(
+					invalidToken(ex.getMessage()), ex);
+		}
+	}
+
+	private TokenIntrospectionSuccessResponse castToNimbusSuccess(TokenIntrospectionResponse introspectionResponse) {
+		if (!introspectionResponse.indicatesSuccess()) {
+			throw new OAuth2AuthenticationException(invalidToken("Token introspection failed"));
+		}
+		return (TokenIntrospectionSuccessResponse) introspectionResponse;
+	}
+
+	private void validate(String token, TokenIntrospectionSuccessResponse response) {
+		// relying solely on the authorization server to validate this token (not checking 'exp', for example)
+		if (!response.isActive()) {
+			throw new OAuth2AuthenticationException(invalidToken("Provided token [" + token + "] isn't active"));
+		}
+	}
+
+	private Map<String, Object> convertClaimsSet(TokenIntrospectionSuccessResponse response) {
+		Map<String, Object> claims = response.toJSONObject();
+		if (response.getAudience() != null) {
+			List<String> audience = response.getAudience().stream()
+					.map(Audience::getValue).collect(Collectors.toList());
+			claims.put(AUDIENCE, Collections.unmodifiableList(audience));
+		}
+		if (response.getClientID() != null) {
+			claims.put(CLIENT_ID, response.getClientID().getValue());
+		}
+		if (response.getExpirationTime() != null) {
+			Instant exp = response.getExpirationTime().toInstant();
+			claims.put(EXPIRES_AT, exp);
+		}
+		if (response.getIssueTime() != null) {
+			Instant iat = response.getIssueTime().toInstant();
+			claims.put(ISSUED_AT, iat);
+		}
+		if (response.getIssuer() != null) {
+			claims.put(ISSUER, issuer(response.getIssuer().getValue()));
+		}
+		if (response.getNotBeforeTime() != null) {
+			claims.put(NOT_BEFORE, response.getNotBeforeTime().toInstant());
+		}
+		if (response.getScope() != null) {
+			claims.put(SCOPE, Collections.unmodifiableList(response.getScope().toStringList()));
+		}
+
+		return claims;
+	}
+
+	private Collection<GrantedAuthority> extractAuthorities(Map<String, Object> claims) {
+		Collection<String> scopes = (Collection<String>) claims.get(SCOPE);
+		return Optional.ofNullable(scopes).orElse(Collections.emptyList())
+				.stream()
+				.map(authority -> new SimpleGrantedAuthority("SCOPE_" + authority))
+				.collect(Collectors.toList());
+	}
+
+	private URL issuer(String uri) {
+		try {
+			return new URL(uri);
+		} catch (Exception ex) {
+			throw new OAuth2AuthenticationException(
+					invalidToken("Invalid " + ISSUER + " value: " + uri), ex);
+		}
+	}
+
+	private static BearerTokenError invalidToken(String message) {
+		return new BearerTokenError("invalid_token",
+				HttpStatus.UNAUTHORIZED, message,
+				"https://tools.ietf.org/html/rfc7662#section-2.2");
+	}
+
+
+	private OAuth2AuthenticationException onError(Throwable e) {
+		OAuth2Error invalidToken = invalidToken(e.getMessage());
+		return new OAuth2AuthenticationException(invalidToken, e.getMessage());
+	}
+}

+ 310 - 0
oauth2/oauth2-resource-server/src/test/java/org/springframework/security/oauth2/server/resource/authentication/OAuth2IntrospectionReactiveAuthenticationManagerTests.java

@@ -0,0 +1,310 @@
+/*
+ * Copyright 2002-2019 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
+ *
+ *      http://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.authentication;
+
+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 net.minidev.json.JSONObject;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.server.resource.BearerTokenAuthenticationToken;
+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.assertThatCode;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.AUDIENCE;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.EXPIRES_AT;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.ISSUER;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.NOT_BEFORE;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SCOPE;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.SUBJECT;
+import static org.springframework.security.oauth2.server.resource.authentication.OAuth2IntrospectionClaimNames.USERNAME;
+
+/**
+ * Tests for {@link OAuth2IntrospectionReactiveAuthenticationManager}
+ */
+public class OAuth2IntrospectionReactiveAuthenticationManagerTests {
+	private static final String INTROSPECTION_URL = "https://server.example.com";
+	private static final String CLIENT_ID = "client";
+	private static final String CLIENT_SECRET = "secret";
+
+	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" +
+			"     }";
+
+	private static final String INACTIVE_RESPONSE = "{\n" +
+			"      \"active\": false\n" +
+			"     }";
+
+	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" +
+			"     }";
+
+	private static final String MALFORMED_ISSUER_RESPONSE = "{\n" +
+			"     \"active\" : \"true\",\n" +
+			"     \"iss\" : \"badissuer\"\n" +
+			"    }";
+
+	@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();
+			OAuth2IntrospectionReactiveAuthenticationManager provider =
+					new OAuth2IntrospectionReactiveAuthenticationManager(introspectUri, CLIENT_ID, CLIENT_SECRET);
+
+			Authentication result =
+					provider.authenticate(new BearerTokenAuthenticationToken("token")).block();
+
+			assertThat(result.getPrincipal()).isInstanceOf(Map.class);
+
+			Map<String, Object> attributes = (Map<String, Object>) result.getPrincipal();
+			assertThat(attributes)
+					.isNotNull()
+					.containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true)
+					.containsEntry(AUDIENCE, Arrays.asList("https://protected.example.net/resource"))
+					.containsEntry(OAuth2IntrospectionClaimNames.CLIENT_ID, "l238j323ds-23ij4")
+					.containsEntry(EXPIRES_AT, Instant.ofEpochSecond(1419356238))
+					.containsEntry(ISSUER, new URL("https://server.example.com/"))
+					.containsEntry(SCOPE, Arrays.asList("read", "write", "dolphin"))
+					.containsEntry(SUBJECT, "Z5O3upPC88QrAjx00dis")
+					.containsEntry(USERNAME, "jdoe")
+					.containsEntry("extension_field", "twenty-seven");
+
+			assertThat(result.getAuthorities()).extracting("authority")
+					.containsExactly("SCOPE_read", "SCOPE_write", "SCOPE_dolphin");
+		}
+	}
+
+	@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();
+			OAuth2IntrospectionReactiveAuthenticationManager provider =
+					new OAuth2IntrospectionReactiveAuthenticationManager(introspectUri, CLIENT_ID, "wrong");
+
+			assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block())
+					.isInstanceOf(OAuth2AuthenticationException.class);
+		}
+	}
+
+	@Test
+	public void authenticateWhenInactiveTokenThenInvalidToken() {
+		WebClient webClient = mockResponse(INACTIVE_RESPONSE);
+		OAuth2IntrospectionReactiveAuthenticationManager provider =
+				new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient);
+
+		assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting("error.errorCode")
+				.containsExactly("invalid_token");
+	}
+
+	@Test
+	public void authenticateWhenActiveTokenThenParsesValuesInResponse() {
+		Map<String, Object> introspectedValues = new HashMap<>();
+		introspectedValues.put(OAuth2IntrospectionClaimNames.ACTIVE, true);
+		introspectedValues.put(AUDIENCE, Arrays.asList("aud"));
+		introspectedValues.put(NOT_BEFORE, 29348723984L);
+
+		WebClient webClient = mockResponse(new JSONObject(introspectedValues).toJSONString());
+		OAuth2IntrospectionReactiveAuthenticationManager provider =
+				new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient);
+
+		Authentication result =
+				provider.authenticate(new BearerTokenAuthenticationToken("token")).block();
+
+		assertThat(result.getPrincipal()).isInstanceOf(Map.class);
+
+		Map<String, Object> attributes = (Map<String, Object>) result.getPrincipal();
+		assertThat(attributes)
+				.isNotNull()
+				.containsEntry(OAuth2IntrospectionClaimNames.ACTIVE, true)
+				.containsEntry(AUDIENCE, Arrays.asList("aud"))
+				.containsEntry(NOT_BEFORE, Instant.ofEpochSecond(29348723984L))
+				.doesNotContainKey(OAuth2IntrospectionClaimNames.CLIENT_ID)
+				.doesNotContainKey(SCOPE);
+
+		assertThat(result.getAuthorities()).isEmpty();
+	}
+
+	@Test
+	public void authenticateWhenIntrospectionEndpointThrowsExceptionThenInvalidToken() {
+		WebClient webClient = mockResponse(new IllegalStateException("server was unresponsive"));
+		OAuth2IntrospectionReactiveAuthenticationManager provider =
+				new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient);
+
+		assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting("error.errorCode")
+				.containsExactly("invalid_token");
+	}
+
+
+	@Test
+	public void authenticateWhenIntrospectionEndpointReturnsMalformedResponseThenInvalidToken() {
+		WebClient webClient = mockResponse("malformed");
+		OAuth2IntrospectionReactiveAuthenticationManager provider =
+				new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient);
+
+		assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting("error.errorCode")
+				.containsExactly("invalid_token");
+	}
+
+	@Test
+	public void authenticateWhenIntrospectionTokenReturnsInvalidResponseThenInvalidToken() {
+		WebClient webClient = mockResponse(INVALID_RESPONSE);
+		OAuth2IntrospectionReactiveAuthenticationManager provider =
+				new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient);
+
+		assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting("error.errorCode")
+				.containsExactly("invalid_token");
+	}
+
+	@Test
+	public void authenticateWhenIntrospectionTokenReturnsMalformedIssuerResponseThenInvalidToken() {
+		WebClient webClient = mockResponse(MALFORMED_ISSUER_RESPONSE);
+		OAuth2IntrospectionReactiveAuthenticationManager provider =
+				new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, webClient);
+
+		assertThatCode(() -> provider.authenticate(new BearerTokenAuthenticationToken("token")).block())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting("error.errorCode")
+				.containsExactly("invalid_token");
+	}
+
+	@Test
+	public void constructorWhenIntrospectionUriIsEmptyThenIllegalArgumentException() {
+		assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager("", CLIENT_ID, CLIENT_SECRET))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenClientIdIsEmptyThenIllegalArgumentException() {
+		assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, "", CLIENT_SECRET))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenClientSecretIsNullThenIllegalArgumentException() {
+		assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, CLIENT_ID, null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void constructorWhenRestOperationsIsNullThenIllegalArgumentException() {
+		assertThatCode(() -> new OAuth2IntrospectionReactiveAuthenticationManager(INTROSPECTION_URL, null))
+				.isInstanceOf(IllegalArgumentException.class);
+	}
+
+	private WebClient mockResponse(String response) {
+		WebClient real = WebClient.builder().build();
+		WebClient.RequestBodyUriSpec spec = spy(real.post());
+		WebClient webClient = spy(WebClient.class);
+		when(webClient.post()).thenReturn(spec);
+		ClientResponse clientResponse = mock(ClientResponse.class);
+		when(clientResponse.rawStatusCode()).thenReturn(200);
+		when(clientResponse.statusCode()).thenReturn(HttpStatus.OK);
+		when(clientResponse.bodyToMono(String.class)).thenReturn(Mono.just(response));
+		ClientResponse.Headers headers = mock(ClientResponse.Headers.class);
+		when(headers.contentType()).thenReturn(Optional.of(MediaType.APPLICATION_JSON_UTF8));
+		when(clientResponse.headers()).thenReturn(headers);
+		when(spec.exchange()).thenReturn(Mono.just(clientResponse));
+		return webClient;
+	}
+
+	private WebClient mockResponse(Throwable t) {
+		WebClient real = WebClient.builder().build();
+		WebClient.RequestBodyUriSpec spec = spy(real.post());
+		WebClient webClient = spy(WebClient.class);
+		when(webClient.post()).thenReturn(spec);
+		when(spec.exchange()).thenThrow(t);
+		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);
+				return Optional.ofNullable(authorization)
+						.filter(a -> isAuthorized(authorization, username, password))
+						.map(a -> ok(response))
+						.orElse(unauthorized());
+			}
+		};
+	}
+
+	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) {
+		return new MockResponse().setBody(response)
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+	}
+
+	private static MockResponse unauthorized() {
+		return new MockResponse().setResponseCode(401);
+	}
+}