浏览代码

Add Token Introspection Endpoint

Closes gh-52
Gerardo Roza 4 年之前
父节点
当前提交
92e8c08ce6
共有 15 个文件被更改,包括 2184 次插入3 次删除
  1. 21 0
      oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java
  2. 162 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaimAccessor.java
  3. 296 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaims.java
  4. 181 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverter.java
  5. 142 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProvider.java
  6. 128 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationToken.java
  7. 22 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java
  8. 167 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilter.java
  9. 229 0
      oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenIntrospectionTests.java
  10. 193 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverterTests.java
  11. 16 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java
  12. 249 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProviderTests.java
  13. 98 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationTokenTests.java
  14. 2 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java
  15. 278 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java

+ 21 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java

@@ -42,6 +42,7 @@ import org.springframework.security.oauth2.server.authorization.authentication.O
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationProvider;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.config.ProviderSettings;
@@ -50,6 +51,7 @@ import org.springframework.security.oauth2.server.authorization.web.NimbusJwkSet
 import org.springframework.security.oauth2.server.authorization.web.OAuth2AuthorizationEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2ClientAuthenticationFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenEndpointFilter;
+import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenIntrospectionEndpointFilter;
 import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenRevocationEndpointFilter;
 import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
 import org.springframework.security.web.authentication.HttpStatusEntryPoint;
@@ -65,12 +67,14 @@ import org.springframework.util.StringUtils;
  *
  * @author Joe Grandja
  * @author Daniel Garnier-Moiroux
+ * @author Gerardo Roza
  * @since 0.0.1
  * @see AbstractHttpConfigurer
  * @see RegisteredClientRepository
  * @see OAuth2AuthorizationService
  * @see OAuth2AuthorizationEndpointFilter
  * @see OAuth2TokenEndpointFilter
+ * @see OAuth2TokenIntrospectionEndpointFilter
  * @see OAuth2TokenRevocationEndpointFilter
  * @see NimbusJwkSetEndpointFilter
  * @see OidcProviderConfigurationEndpointFilter
@@ -81,12 +85,14 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 
 	private RequestMatcher authorizationEndpointMatcher;
 	private RequestMatcher tokenEndpointMatcher;
+	private RequestMatcher tokenIntrospectionEndpointMatcher;
 	private RequestMatcher tokenRevocationEndpointMatcher;
 	private RequestMatcher jwkSetEndpointMatcher;
 	private RequestMatcher oidcProviderConfigurationEndpointMatcher;
 	private final RequestMatcher endpointsMatcher = (request) ->
 			this.authorizationEndpointMatcher.matches(request) ||
 			this.tokenEndpointMatcher.matches(request) ||
+			this.tokenIntrospectionEndpointMatcher.matches(request) ||
 			this.tokenRevocationEndpointMatcher.matches(request) ||
 			this.jwkSetEndpointMatcher.matches(request) ||
 			this.oidcProviderConfigurationEndpointMatcher.matches(request);
@@ -183,12 +189,18 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 						getAuthorizationService(builder));
 		builder.authenticationProvider(postProcess(tokenRevocationAuthenticationProvider));
 
+		OAuth2TokenIntrospectionAuthenticationProvider tokenIntrospectionAuthenticationProvider =
+				new OAuth2TokenIntrospectionAuthenticationProvider(
+						getAuthorizationService(builder));
+		builder.authenticationProvider(postProcess(tokenIntrospectionAuthenticationProvider));
+
 		ExceptionHandlingConfigurer<B> exceptionHandling = builder.getConfigurer(ExceptionHandlingConfigurer.class);
 		if (exceptionHandling != null) {
 			exceptionHandling.defaultAuthenticationEntryPointFor(
 					new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED),
 					new OrRequestMatcher(
 							this.tokenEndpointMatcher,
+							this.tokenIntrospectionEndpointMatcher,
 							this.tokenRevocationEndpointMatcher)
 			);
 		}
@@ -216,6 +228,7 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 						authenticationManager,
 						new OrRequestMatcher(
 								this.tokenEndpointMatcher,
+								this.tokenIntrospectionEndpointMatcher,
 								this.tokenRevocationEndpointMatcher));
 		builder.addFilterAfter(postProcess(clientAuthenticationFilter), AbstractPreAuthenticatedProcessingFilter.class);
 
@@ -237,6 +250,12 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 						authenticationManager,
 						providerSettings.tokenRevocationEndpoint());
 		builder.addFilterAfter(postProcess(tokenRevocationEndpointFilter), OAuth2TokenEndpointFilter.class);
+
+		OAuth2TokenIntrospectionEndpointFilter tokenIntrospectionEndpointFilter =
+				new OAuth2TokenIntrospectionEndpointFilter(
+						authenticationManager,
+						providerSettings.tokenIntrospectionEndpoint());
+		builder.addFilterAfter(postProcess(tokenIntrospectionEndpointFilter), OAuth2TokenEndpointFilter.class);
 	}
 
 	private void initEndpointMatchers(ProviderSettings providerSettings) {
@@ -249,6 +268,8 @@ public final class OAuth2AuthorizationServerConfigurer<B extends HttpSecurityBui
 						HttpMethod.POST.name()));
 		this.tokenEndpointMatcher = new AntPathRequestMatcher(
 				providerSettings.tokenEndpoint(), HttpMethod.POST.name());
+		this.tokenIntrospectionEndpointMatcher = new AntPathRequestMatcher(
+				providerSettings.tokenIntrospectionEndpoint(), HttpMethod.POST.name());
 		this.tokenRevocationEndpointMatcher = new AntPathRequestMatcher(
 				providerSettings.tokenRevocationEndpoint(), HttpMethod.POST.name());
 		this.jwkSetEndpointMatcher = new AntPathRequestMatcher(

+ 162 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaimAccessor.java

@@ -0,0 +1,162 @@
+/*
+ * Copyright 2020-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.core;
+
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_ID;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.TOKEN_TYPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.USERNAME;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.AUD;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.EXP;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.IAT;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.JTI;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.NBF;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB;
+
+import java.net.URL;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * TODO This class is a copy from Spring Security (Resource Server) with the difference that we rely on the existing
+ * {@code OAuth2ParameterNames} and {@code JwtClaimNames} claims. It should be consolidated when merging this codebase into Spring
+ * Security.
+ *
+ * A {@link ClaimAccessor} for the &quot;claims&quot; that may be contained in the Introspection Response.
+ *
+ * @author David Kovac
+ * @author Gerardo Roza
+ * @since 0.1.1
+ * @see ClaimAccessor
+ * @see OAuth2IntrospectionClaimNames
+ * @see OAuth2IntrospectionAuthenticatedPrincipal
+ * @see <a target="_blank" href= "https://tools.ietf.org/html/rfc7662#section-2.2">Introspection Response</a>
+ */
+public interface OAuth2TokenIntrospectionClaimAccessor extends ClaimAccessor {
+
+	String ACTIVE = "active";
+
+	/**
+	 * Returns the indicator {@code (active)} whether or not the token is currently active
+	 *
+	 * @return the indicator whether or not the token is currently active
+	 */
+	default boolean isActive() {
+		return Boolean.TRUE.equals(this.getClaimAsBoolean(ACTIVE));
+	}
+
+	/**
+	 * Returns the scopes {@code (scope)} associated with the token
+	 *
+	 * @return the scopes associated with the token
+	 */
+	default String getScope() {
+		return this.getClaimAsString(SCOPE);
+	}
+
+	/**
+	 * Returns the client identifier {@code (client_id)} for the token
+	 *
+	 * @return the client identifier for the token
+	 */
+	default String getClientId() {
+		return this.getClaimAsString(CLIENT_ID);
+	}
+
+	/**
+	 * Returns a human-readable identifier {@code (username)} for the resource owner that authorized the token
+	 *
+	 * @return a human-readable identifier for the resource owner that authorized the token
+	 */
+	default String getUsername() {
+		return this.getClaimAsString(USERNAME);
+	}
+
+	/**
+	 * Returns the type of the token {@code (token_type)}, for example {@code bearer}.
+	 *
+	 * @return the type of the token, for example {@code bearer}.
+	 */
+	default String getTokenType() {
+		return this.getClaimAsString(TOKEN_TYPE);
+	}
+
+	/**
+	 * Returns a timestamp {@code (exp)} indicating when the token expires
+	 *
+	 * @return a timestamp indicating when the token expires
+	 */
+	default Instant getExpiresAt() {
+		return this.getClaimAsInstant(EXP);
+	}
+
+	/**
+	 * Returns a timestamp {@code (iat)} indicating when the token was issued
+	 *
+	 * @return a timestamp indicating when the token was issued
+	 */
+	default Instant getIssuedAt() {
+		return this.getClaimAsInstant(IAT);
+	}
+
+	/**
+	 * Returns a timestamp {@code (nbf)} indicating when the token is not to be used before
+	 *
+	 * @return a timestamp indicating when the token is not to be used before
+	 */
+	default Instant getNotBefore() {
+		return this.getClaimAsInstant(NBF);
+	}
+
+	/**
+	 * Returns usually a machine-readable identifier {@code (sub)} of the resource owner who authorized the token
+	 *
+	 * @return usually a machine-readable identifier of the resource owner who authorized the token
+	 */
+	default String getSubject() {
+		return this.getClaimAsString(SUB);
+	}
+
+	/**
+	 * Returns the intended audience {@code (aud)} for the token
+	 *
+	 * @return the intended audience for the token
+	 */
+	default List<String> getAudience() {
+		return this.getClaimAsStringList(AUD);
+	}
+
+	/**
+	 * Returns the issuer {@code (iss)} of the token
+	 *
+	 * @return the issuer of the token
+	 */
+	default URL getIssuer() {
+		return this.getClaimAsURL(ISS);
+	}
+
+	/**
+	 * Returns the identifier {@code (jti)} for the token
+	 *
+	 * @return the identifier for the token
+	 */
+	default String getId() {
+		return this.getClaimAsString(JTI);
+	}
+
+}

+ 296 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaims.java

@@ -0,0 +1,296 @@
+/*
+ * Copyright 2020-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.core;
+
+import static java.util.stream.Collectors.toMap;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_ID;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.TOKEN_TYPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.USERNAME;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.AUD;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.EXP;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.IAT;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.JTI;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.NBF;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB;
+
+import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
+import org.springframework.util.Assert;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+/**
+ * A representation of an OAuth 2.0 Introspection Token Response Claims.
+ *
+ * @author Gerardo Roza
+ * @since 0.1.1
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7662#section-2.2">Section 2.2 - Introspection Response</a>
+ */
+public class OAuth2TokenIntrospectionClaims implements OAuth2TokenIntrospectionClaimAccessor, Serializable {
+	private static final long serialVersionUID = Version.SERIAL_VERSION_UID;
+	private static final Collection<String> SUPPORTED_FIELDS = Arrays
+			.asList(ACTIVE, CLIENT_ID, SCOPE, TOKEN_TYPE, USERNAME, AUD, EXP, IAT, ISS, JTI, NBF, SUB);
+
+	private final Map<String, Object> claims;
+
+	private OAuth2TokenIntrospectionClaims(Map<String, Object> claims) {
+		this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims));
+	}
+
+	/**
+	 * Returns the populated Token Introspection Response claims.
+	 *
+	 * @return the claims
+	 */
+	@Override
+	public Map<String, Object> getClaims() {
+		return this.claims;
+	}
+
+	/**
+	 * Constructs a new {@link Builder} with the provided parameters.
+	 *
+	 * @param claims the params to initialize the builder
+	 */
+	public static Builder withClaims(Map<String, Object> claims) {
+		Assert.notEmpty(claims, "claims cannot be empty");
+		Builder builder = new Builder().claims(c -> c.putAll(claims));
+		Optional.ofNullable(claims.get(IAT)).filter(Instant.class::isInstance).map(Instant.class::cast)
+				.ifPresent(builder::issuedAt);
+		Optional.ofNullable(claims.get(EXP)).filter(Instant.class::isInstance).map(Instant.class::cast)
+				.ifPresent(builder::expirationTime);
+		Optional.ofNullable(claims.get(NBF)).filter(Instant.class::isInstance).map(Instant.class::cast)
+				.ifPresent(builder::notBefore);
+		Optional.ofNullable(claims.get(TOKEN_TYPE)).filter(TokenType.class::isInstance).map(TokenType.class::cast)
+				.ifPresent(builder::tokenType);
+		return builder;
+	}
+
+	/**
+	 * Constructs a new {@link Builder} with the required active field.
+	 *
+	 * @param active boolean indicating whether the introspected token is active or not to initialize the builder
+	 */
+	public static Builder builder(boolean active) {
+		return new Builder(active);
+	}
+
+	/**
+	 * A builder for {@link OAuth2TokenIntrospectionClaims}.
+	 */
+	public static final class Builder {
+
+		private final Map<String, Object> claims = new LinkedHashMap<>();
+
+		private Builder() {
+		}
+
+		/**
+		 * Helps configure a basic {@link OAuth2TokenIntrospectionClaims}.
+		 *
+		 * @param active boolean indicating whether the introspected token is active or not
+		 */
+		public Builder(boolean active) {
+			claim(ACTIVE, active);
+		}
+
+		/**
+		 * Populates the 'active' field.
+		 *
+		 * @param active boolean indicating whether the introspected token is active or not
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder active(boolean active) {
+			claim(ACTIVE, active);
+			return this;
+		}
+
+		/**
+		 * Populates the 'scope' field.
+		 *
+		 * @param scope string containing a space-separated list of scopes associated with this token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder scope(String scope) {
+			claim(SCOPE, scope);
+			return this;
+		}
+
+		/**
+		 * Populates the 'client_id' field.
+		 *
+		 * @param clientId identifier for the OAuth 2.0 client that requested this token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder clientId(String clientId) {
+			claim(CLIENT_ID, clientId);
+			return this;
+		}
+
+		/**
+		 * Populates the 'username' field.
+		 *
+		 * @param username Human-readable identifier for the resource owner who authorized this token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder username(String username) {
+			claim(USERNAME, username);
+			return this;
+		}
+
+		/**
+		 * Populates the 'token_type' field.
+		 *
+		 * @param tokenType {@link TokenType} indicating the type of the token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder tokenType(OAuth2AccessToken.TokenType tokenType) {
+			claim(TOKEN_TYPE, tokenType.getValue());
+			return this;
+		}
+
+		/**
+		 * Populates the 'exp' (Expiration Time) field.
+		 *
+		 * @param expirationTime {@link Instant} indicating when this token will expire
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder expirationTime(Instant expirationTime) {
+			claim(EXP, expirationTime.getEpochSecond());
+			return this;
+		}
+
+		/**
+		 * Populates the 'iat' (Issued At) field.
+		 *
+		 * @param issuedAt {@link Instant} indicating when this token was originally issued
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder issuedAt(Instant issuedAt) {
+			claim(IAT, issuedAt.getEpochSecond());
+			return this;
+		}
+
+		/**
+		 * Populates the 'nbf' (Not Before) field.
+		 *
+		 * @param notBefore {@link Instant} indicating when this token is not to be used before
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder notBefore(Instant notBefore) {
+			claim(NBF, notBefore.getEpochSecond());
+			return this;
+		}
+
+		/**
+		 * Populates the 'sub' (Subject) field.
+		 *
+		 * @param subject usually a machine-readable identifier of the resource owner who authorized this token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder subject(String subject) {
+			claim(SUB, subject);
+			return this;
+		}
+
+		/**
+		 * Populates the 'aud' (Audience) field.
+		 *
+		 * @param audience service-specific string identifier or list of string identifiers representing the intended audience for this
+		 * token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder audience(List<String> audience) {
+			claim(AUD, audience);
+			return this;
+		}
+
+		/**
+		 * Populates the 'iss' (Issuer) field.
+		 *
+		 * @param issuer of this token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder issuer(String issuer) {
+			claim(ISS, issuer);
+			return this;
+		}
+
+		/**
+		 * Populates the 'jti' (JWT ID) field.
+		 *
+		 * @param jwtId identifier for the token
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder jwtId(String jwtId) {
+			claim(JTI, jwtId);
+			return this;
+		}
+
+		/**
+		 * Use this claim in the resulting {@link OAuth2TokenIntrospectionClaims}.
+		 *
+		 * @param name the claim name
+		 * @param value the claim value
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder claim(String name, Object value) {
+			Assert.hasText(name, "name cannot be empty");
+			Assert.notNull(value, "value cannot be null");
+			this.claims.put(name, value);
+			return this;
+		}
+
+		/**
+		 * Provides access to every {@link #claim(String, Object)} declared so far with the possibility to add, replace, or remove.
+		 *
+		 * @param claimsConsumer a {@code Consumer} of the claims
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder claims(Consumer<Map<String, Object>> claimsConsumer) {
+			claimsConsumer.accept(this.claims);
+			return this;
+		}
+
+		private void validateClaims() {
+			Assert.notNull(this.claims.get(ACTIVE), "active cannot be null");
+		}
+
+		/**
+		 * Build the {@link OAuth2TokenIntrospectionClaims}
+		 *
+		 * @return The constructed {@link OAuth2TokenIntrospectionClaims}
+		 */
+		public OAuth2TokenIntrospectionClaims build() {
+			Map<String, Object> responseClaims = this.claims.entrySet().stream()
+					.filter(entry -> SUPPORTED_FIELDS.contains(entry.getKey()))
+					.collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
+			validateClaims();
+			return new OAuth2TokenIntrospectionClaims(responseClaims);
+		}
+	}
+}

+ 181 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverter.java

@@ -0,0 +1,181 @@
+/*
+ * Copyright 2020-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.core.http.converter;
+
+import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor.ACTIVE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_ID;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.TOKEN_TYPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.USERNAME;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.AUD;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.EXP;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.IAT;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.JTI;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.NBF;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB;
+
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.core.convert.TypeDescriptor;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpOutputMessage;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.AbstractHttpMessageConverter;
+import org.springframework.http.converter.GenericHttpMessageConverter;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaims;
+import org.springframework.security.oauth2.core.converter.ClaimConversionService;
+import org.springframework.security.oauth2.core.converter.ClaimTypeConverter;
+import org.springframework.util.Assert;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A {@link HttpMessageConverter} for an {@link OAuth2TokenIntrospectionClaims} Token Introspection Response.
+ *
+ * @author Gerardo Roza
+ * @since 0.1.1
+ * @see AbstractHttpMessageConverter
+ * @see OAuth2TokenIntrospectionClaims
+ */
+public class OAuth2TokenIntrospectionClaimsHttpMessageConverter
+		extends AbstractHttpMessageConverter<OAuth2TokenIntrospectionClaims> {
+
+	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
+	};
+
+	private GenericHttpMessageConverter<Object> jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter();
+
+	private Converter<Map<String, Object>, OAuth2TokenIntrospectionClaims> tokenIntrospectionResponseConverter = new OAuth2TokenIntrospectionResponseConverter();
+	private Converter<OAuth2TokenIntrospectionClaims, Map<String, Object>> tokenIntrospectionResponseParametersConverter = OAuth2TokenIntrospectionClaims::getClaims;
+
+	public OAuth2TokenIntrospectionClaimsHttpMessageConverter() {
+		super(MediaType.APPLICATION_JSON, new MediaType("application", "*+json"));
+	}
+
+	@Override
+	protected boolean supports(Class<?> clazz) {
+		return OAuth2TokenIntrospectionClaims.class.isAssignableFrom(clazz);
+	}
+
+	@Override
+	@SuppressWarnings("unchecked")
+	protected OAuth2TokenIntrospectionClaims readInternal(Class<? extends OAuth2TokenIntrospectionClaims> clazz,
+			HttpInputMessage inputMessage) throws HttpMessageNotReadableException {
+		try {
+			Map<String, Object> tokenIntrospectionResponseParameters = (Map<String, Object>) this.jsonMessageConverter
+					.read(STRING_OBJECT_MAP.getType(), null, inputMessage);
+			return this.tokenIntrospectionResponseConverter.convert(tokenIntrospectionResponseParameters);
+		} catch (Exception ex) {
+			throw new HttpMessageNotReadableException(
+					"An error occurred reading the Token Introspection Response: " + ex.getMessage(), ex, inputMessage);
+		}
+	}
+
+	@Override
+	protected void writeInternal(OAuth2TokenIntrospectionClaims tokenIntrospectionResponse,
+			HttpOutputMessage outputMessage) {
+		try {
+
+			Map<String, Object> tokenIntrospectionResponseParameters = this.tokenIntrospectionResponseParametersConverter
+					.convert(tokenIntrospectionResponse);
+			this.jsonMessageConverter.write(
+					tokenIntrospectionResponseParameters, STRING_OBJECT_MAP.getType(), MediaType.APPLICATION_JSON,
+					outputMessage);
+		} catch (Exception ex) {
+			throw new HttpMessageNotWritableException(
+					"An error occurred writing the Token Introspection Response: " + ex.getMessage(), ex);
+		}
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the Token Introspection parameters to an {@link OAuth2TokenIntrospectionClaims}.
+	 *
+	 * @param tokenIntrospectionResponseConverter the {@link Converter} used for converting to an
+	 * {@link OAuth2TokenIntrospectionClaims}
+	 */
+	public void setTokenIntrospectionResponseConverter(
+			Converter<Map<String, Object>, OAuth2TokenIntrospectionClaims> tokenIntrospectionResponseConverter) {
+		Assert.notNull(tokenIntrospectionResponseConverter, "tokenIntrospectionResponseConverter cannot be null");
+		this.tokenIntrospectionResponseConverter = tokenIntrospectionResponseConverter;
+	}
+
+	/**
+	 * Sets the {@link Converter} used for converting the {@link OAuth2TokenIntrospectionClaims} to a {@code Map} representation of
+	 * the Token Introspection Response.
+	 *
+	 * @param tokenIntrospectionResponseParametersConverter the {@link Converter} used for converting to a {@code Map} representation
+	 * of the Token Introspection Response
+	 */
+	public void setTokenIntrospectionResponseParametersConverter(
+			Converter<OAuth2TokenIntrospectionClaims, Map<String, Object>> tokenIntrospectionResponseParametersConverter) {
+		Assert.notNull(
+				tokenIntrospectionResponseParametersConverter,
+				"tokenIntrospectionResponseParametersConverter cannot be null");
+		this.tokenIntrospectionResponseParametersConverter = tokenIntrospectionResponseParametersConverter;
+	}
+
+	private static final class OAuth2TokenIntrospectionResponseConverter
+			implements Converter<Map<String, Object>, OAuth2TokenIntrospectionClaims> {
+		private static final ClaimConversionService CLAIM_CONVERSION_SERVICE = ClaimConversionService
+				.getSharedInstance();
+		private static final TypeDescriptor OBJECT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Object.class);
+		private static final TypeDescriptor BOOLEAN_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Boolean.class);
+		private static final TypeDescriptor INSTANT_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(Instant.class);
+		private static final TypeDescriptor STRING_TYPE_DESCRIPTOR = TypeDescriptor.valueOf(String.class);
+		private final ClaimTypeConverter parameterTypeConverter;
+
+		private OAuth2TokenIntrospectionResponseConverter() {
+			Converter<Object, ?> collectionStringConverter = getConverter(
+					TypeDescriptor.collection(Collection.class, STRING_TYPE_DESCRIPTOR));
+			Converter<Object, ?> stringConverter = getConverter(STRING_TYPE_DESCRIPTOR);
+			Converter<Object, ?> booleanConverter = getConverter(BOOLEAN_TYPE_DESCRIPTOR);
+			Converter<Object, ?> instantConverter = getConverter(INSTANT_TYPE_DESCRIPTOR);
+
+			Map<String, Converter<Object, ?>> claimConverters = new HashMap<>();
+			claimConverters.put(ACTIVE, booleanConverter);
+			claimConverters.put(SCOPE, stringConverter);
+			claimConverters.put(CLIENT_ID, stringConverter);
+			claimConverters.put(TOKEN_TYPE, stringConverter);
+			claimConverters.put(USERNAME, stringConverter);
+			claimConverters.put(AUD, collectionStringConverter);
+			claimConverters.put(EXP, instantConverter);
+			claimConverters.put(IAT, instantConverter);
+			claimConverters.put(ISS, stringConverter);
+			claimConverters.put(JTI, stringConverter);
+			claimConverters.put(NBF, instantConverter);
+			claimConverters.put(SUB, stringConverter);
+			this.parameterTypeConverter = new ClaimTypeConverter(claimConverters);
+		}
+
+		@Override
+		public OAuth2TokenIntrospectionClaims convert(Map<String, Object> source) {
+			Map<String, Object> parsedClaims = this.parameterTypeConverter.convert(source);
+			return OAuth2TokenIntrospectionClaims.withClaims(parsedClaims).build();
+		}
+
+		private static Converter<Object, ?> getConverter(TypeDescriptor targetDescriptor) {
+			return (source) -> CLAIM_CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor);
+		}
+	}
+}

+ 142 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProvider.java

@@ -0,0 +1,142 @@
+/*
+ * Copyright 2020-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.authorization.authentication;
+
+import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor.ACTIVE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_ID;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.TOKEN_TYPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.USERNAME;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.EXP;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.IAT;
+import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient;
+
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.core.AbstractOAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization.Token;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.util.Assert;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * An {@link AuthenticationProvider} implementation for OAuth 2.0 Token Introspection.
+ *
+ * @author Gerardo Roza
+ * @since 0.1.1
+ * @see OAuth2TokenIntrospectionAuthenticationToken
+ * @see OAuth2AuthorizationService
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7662#section-2.1">Section 2.1 - Introspection Request</a>
+ */
+public class OAuth2TokenIntrospectionAuthenticationProvider implements AuthenticationProvider {
+	private final OAuth2AuthorizationService authorizationService;
+
+	/**
+	 * Constructs an {@code OAuth2TokenIntrospectionAuthenticationProvider} using the provided parameters.
+	 *
+	 * @param authorizationService the authorization service
+	 */
+	public OAuth2TokenIntrospectionAuthenticationProvider(OAuth2AuthorizationService authorizationService) {
+		Assert.notNull(authorizationService, "authorizationService cannot be null");
+		this.authorizationService = authorizationService;
+	}
+
+	@Override
+	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+		OAuth2TokenIntrospectionAuthenticationToken tokenIntrospectionAuthentication = (OAuth2TokenIntrospectionAuthenticationToken) authentication;
+
+		OAuth2ClientAuthenticationToken clientPrincipal = getAuthenticatedClientElseThrowInvalidClient(
+				tokenIntrospectionAuthentication);
+		RegisteredClient registeredClient = clientPrincipal.getRegisteredClient();
+
+		OAuth2Authorization authorization = this.authorizationService
+				.findByToken(tokenIntrospectionAuthentication.getTokenValue(), null);
+		if (authorization == null) {
+			return generateAuthenticationTokenForInvalidToken(clientPrincipal, registeredClient);
+		}
+
+		Token<AbstractOAuth2Token> tokenHolder = authorization
+				.getToken(tokenIntrospectionAuthentication.getTokenValue());
+
+		if (tokenHolder.isInvalidated()) {
+			return generateAuthenticationTokenForInvalidToken(clientPrincipal, registeredClient);
+		}
+
+		if (isExpired(tokenHolder.getToken())
+				|| (tokenHolder.getClaims() != null && hasInvalidClaims(tokenHolder.getClaims()))) {
+			return generateAuthenticationTokenForInvalidToken(clientPrincipal, registeredClient);
+		}
+
+		Map<String, Object> claims = generateTokenIntrospectionClaims(
+				tokenHolder, registeredClient.getClientId(), authorization.getPrincipalName());
+
+		return new OAuth2TokenIntrospectionAuthenticationToken(clientPrincipal, claims);
+	}
+
+	@Override
+	public boolean supports(Class<?> authentication) {
+		return OAuth2TokenIntrospectionAuthenticationToken.class.isAssignableFrom(authentication);
+	}
+
+	private boolean isExpired(AbstractOAuth2Token token) {
+		Instant expiry = token.getExpiresAt();
+		return (expiry != null && Instant.now().isAfter(expiry));
+	}
+
+	private boolean hasInvalidClaims(Map<String, Object> claims) {
+		Object notBeforeValue = claims.get(JwtClaimNames.NBF);
+		if (notBeforeValue != null && Instant.class.isAssignableFrom(notBeforeValue.getClass())) {
+			Instant notBefore = (Instant) notBeforeValue;
+			return Instant.now().isBefore(notBefore);
+		}
+		return false;
+	}
+
+	private Map<String, Object> generateTokenIntrospectionClaims(Token<? extends AbstractOAuth2Token> tokenHolder,
+			String clientId, String username) {
+		Map<String, Object> claims = Optional.ofNullable(tokenHolder.getClaims()).map(LinkedHashMap::new)
+				.orElse(new LinkedHashMap<>());
+		AbstractOAuth2Token token = tokenHolder.getToken();
+		claims.put(ACTIVE, true);
+		claims.put(CLIENT_ID, clientId);
+		claims.put(USERNAME, username);
+		Optional.ofNullable(token.getIssuedAt()).ifPresent(iat -> claims.put(IAT, iat));
+		Optional.ofNullable(token.getExpiresAt()).ifPresent(exp -> claims.put(EXP, exp));
+		if (OAuth2AccessToken.class.isAssignableFrom(token.getClass())) {
+			Collection<String> scopes = ((OAuth2AccessToken) token).getScopes();
+			if (!scopes.isEmpty()) {
+				claims.put(SCOPE, String.join(" ", scopes));
+			}
+			claims.put(TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER);
+		}
+		return claims;
+	}
+
+	private OAuth2TokenIntrospectionAuthenticationToken generateAuthenticationTokenForInvalidToken(
+			Authentication clientPrincipal, RegisteredClient registeredClient) {
+		return new OAuth2TokenIntrospectionAuthenticationToken(clientPrincipal, null);
+	}
+}

+ 128 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationToken.java

@@ -0,0 +1,128 @@
+/*
+ * Copyright 2020-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.authorization.authentication;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.Version;
+import org.springframework.util.Assert;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * An {@link Authentication} implementation used for OAuth 2.0 Token Introspection.
+ *
+ * @author Gerardo Roza
+ * @since 0.1.1
+ * @see AbstractAuthenticationToken
+ * @see OAuth2TokenIntrospectionAuthenticationProvider
+ */
+public class OAuth2TokenIntrospectionAuthenticationToken extends AbstractAuthenticationToken {
+	private static final long serialVersionUID = Version.SERIAL_VERSION_UID;
+	private final String tokenValue;
+	private final Authentication clientPrincipal;
+	private final String tokenTypeHint;
+	private Map<String, Object> claims;
+
+	/**
+	 * Constructs an {@code OAuth2TokenIntrospectionAuthenticationToken} using the provided parameters.
+	 *
+	 * @param tokenValue the token
+	 * @param clientPrincipal the authenticated client principal
+	 * @param tokenTypeHint the token type hint
+	 */
+	public OAuth2TokenIntrospectionAuthenticationToken(String tokenValue, Authentication clientPrincipal,
+			@Nullable String tokenTypeHint) {
+		super(Collections.emptyList());
+		Assert.hasText(tokenValue, "token cannot be empty");
+		Assert.notNull(clientPrincipal, "clientPrincipal cannot be null");
+		this.tokenValue = tokenValue;
+		this.clientPrincipal = clientPrincipal;
+		this.tokenTypeHint = tokenTypeHint;
+		this.claims = null;
+	}
+
+	/**
+	 * Constructs an {@code OAuth2TokenIntrospectionAuthenticationToken} using the provided parameters.
+	 *
+	 * The {@code claims} should be provided only if the token is active.
+	 *
+	 * @param claims the claims obtained from the introspected active token
+	 * @param clientPrincipal the authenticated client principal
+	 */
+	public OAuth2TokenIntrospectionAuthenticationToken(Authentication clientPrincipal,
+			@Nullable Map<String, Object> claims) {
+		super(Collections.emptyList());
+		Assert.notNull(clientPrincipal, "clientPrincipal cannot be null");
+		this.claims = claims;
+		this.clientPrincipal = clientPrincipal;
+		this.tokenTypeHint = null;
+		this.tokenValue = null;
+		setAuthenticated(true); // Indicates that the request was authenticated, even though the introspected token might not be
+								// active
+	}
+
+	@Override
+	public Object getPrincipal() {
+		return this.clientPrincipal;
+	}
+
+	@Override
+	public Object getCredentials() {
+		return "";
+	}
+
+	/**
+	 * Returns the token value.
+	 *
+	 * @return the token value
+	 */
+	public String getTokenValue() {
+		return this.tokenValue;
+	}
+
+	/**
+	 * Returns the token type hint.
+	 *
+	 * @return the token type hint
+	 */
+	@Nullable
+	public String getTokenTypeHint() {
+		return this.tokenTypeHint;
+	}
+
+	/**
+	 * Returns the introspection claims.
+	 *
+	 * @return the claims
+	 */
+	public Map<String, Object> getClaims() {
+		return claims;
+	}
+
+	/**
+	 * Returns whether the introspected token is active, having in mind only active tokens' claims should be passed to the
+	 * constructor.
+	 *
+	 * @return whether the introspected token is active or not
+	 */
+	public boolean isTokenActive() {
+		return this.claims != null;
+	}
+
+}

+ 22 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020 the original author or authors.
+ * Copyright 2020-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.
@@ -32,6 +32,7 @@ public class ProviderSettings extends Settings {
 	public static final String TOKEN_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-endpoint");
 	public static final String JWK_SET_ENDPOINT = PROVIDER_SETTING_BASE.concat("jwk-set-endpoint");
 	public static final String TOKEN_REVOCATION_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-revocation-endpoint");
+	public static final String TOKEN_INTROSPECTION_ENDPOINT = PROVIDER_SETTING_BASE.concat("token-introspection-endpoint");
 
 	/**
 	 * Constructs a {@code ProviderSettings}.
@@ -144,12 +145,32 @@ public class ProviderSettings extends Settings {
 		return setting(TOKEN_REVOCATION_ENDPOINT, tokenRevocationEndpoint);
 	}
 
+	/**
+	 * Returns the Provider's OAuth 2.0 Token Introspection endpoint. The default is {@code /oauth2/introspect}.
+	 *
+	 * @return the Token Introspection endpoint
+	 */
+	public String tokenIntrospectionEndpoint() {
+		return setting(TOKEN_INTROSPECTION_ENDPOINT);
+	}
+
+	/**
+	 * Sets the Provider's OAuth 2.0 Token Introspection endpoint.
+	 *
+	 * @param tokenIntrospectionEndpoint the Token Introspection endpoint
+	 * @return the {@link ProviderSettings} for further configuration
+	 */
+	public ProviderSettings tokenIntrospectionEndpoint(String tokenIntrospectionEndpoint) {
+		return setting(TOKEN_INTROSPECTION_ENDPOINT, tokenIntrospectionEndpoint);
+	}
+
 	protected static Map<String, Object> defaultSettings() {
 		Map<String, Object> settings = new HashMap<>();
 		settings.put(AUTHORIZATION_ENDPOINT, "/oauth2/authorize");
 		settings.put(TOKEN_ENDPOINT, "/oauth2/token");
 		settings.put(JWK_SET_ENDPOINT, "/oauth2/jwks");
 		settings.put(TOKEN_REVOCATION_ENDPOINT, "/oauth2/revoke");
+		settings.put(TOKEN_INTROSPECTION_ENDPOINT, "/oauth2/introspect");
 		return settings;
 	}
 }

+ 167 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilter.java

@@ -0,0 +1,167 @@
+/*
+ * Copyright 2020-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.authorization.web;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaims;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames2;
+import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.oauth2.core.http.converter.OAuth2TokenIntrospectionClaimsHttpMessageConverter;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationToken;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * A {@code Filter} for the OAuth 2.0 Token Introspection endpoint.
+ *
+ * @author Gerardo Roza
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7662#section-2">Section 2 - Introspection Endpoint</a>
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc7662#section-2.1">Section 2.1 - Introspection Request</a>
+ * @since 0.1.1
+ */
+public class OAuth2TokenIntrospectionEndpointFilter extends OncePerRequestFilter {
+
+	/**
+	 * The default endpoint {@code URI} for token introspection requests.
+	 */
+	public static final String DEFAULT_TOKEN_INTROSPECTION_ENDPOINT_URI = "/oauth2/introspect";
+
+	private final AuthenticationManager authenticationManager;
+	private final RequestMatcher tokenIntrospectionEndpointMatcher;
+	private final Converter<HttpServletRequest, Authentication> tokenIntrospectionAuthenticationConverter = new DefaultTokenIntrospectionAuthenticationConverter();
+	private final HttpMessageConverter<OAuth2TokenIntrospectionClaims> tokenIntrospectionHttpResponseConverter = new OAuth2TokenIntrospectionClaimsHttpMessageConverter();
+
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter = new OAuth2ErrorHttpMessageConverter();
+
+	/**
+	 * Constructs an {@code OAuth2TokenIntrospectionEndpointFilter} using the provided parameters.
+	 *
+	 * @param authenticationManager the authentication manager
+	 */
+	public OAuth2TokenIntrospectionEndpointFilter(AuthenticationManager authenticationManager) {
+		this(authenticationManager, DEFAULT_TOKEN_INTROSPECTION_ENDPOINT_URI);
+	}
+
+	/**
+	 * Constructs an {@code OAuth2TokenIntrospectionEndpointFilter} using the provided parameters.
+	 *
+	 * @param authenticationManager the authentication manager
+	 * @param tokenIntrospectionEndpointUri the endpoint {@code URI} for token introspection requests
+	 */
+	public OAuth2TokenIntrospectionEndpointFilter(AuthenticationManager authenticationManager,
+			String tokenIntrospectionEndpointUri) {
+		Assert.notNull(authenticationManager, "authenticationManager cannot be null");
+		Assert.hasText(tokenIntrospectionEndpointUri, "tokenIntrospectionEndpointUri cannot be empty");
+		this.authenticationManager = authenticationManager;
+		this.tokenIntrospectionEndpointMatcher = new AntPathRequestMatcher(
+				tokenIntrospectionEndpointUri, HttpMethod.POST.name());
+	}
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+			throws ServletException, IOException {
+
+		if (!this.tokenIntrospectionEndpointMatcher.matches(request)) {
+			filterChain.doFilter(request, response);
+			return;
+		}
+
+		try {
+
+			Authentication authentication = this.tokenIntrospectionAuthenticationConverter.convert(request);
+
+			OAuth2TokenIntrospectionAuthenticationToken tokenIntrospectionAuthentication = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationManager
+					.authenticate(authentication);
+
+			OAuth2TokenIntrospectionClaims tokenIntrospectionResponse = tokenIntrospectionAuthentication
+					.isTokenActive()
+							? OAuth2TokenIntrospectionClaims.withClaims(tokenIntrospectionAuthentication.getClaims())
+									.build()
+							: OAuth2TokenIntrospectionClaims.builder(false).build();
+
+			sendTokenIntrospectionResponse(response, tokenIntrospectionResponse);
+		} catch (OAuth2AuthenticationException ex) {
+			SecurityContextHolder.clearContext();
+			sendErrorResponse(response, ex.getError());
+		}
+	}
+
+	private void sendErrorResponse(HttpServletResponse response, OAuth2Error error) throws IOException {
+		ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
+		httpResponse.setStatusCode(HttpStatus.BAD_REQUEST);
+		this.errorHttpResponseConverter.write(error, null, httpResponse);
+	}
+
+	private void sendTokenIntrospectionResponse(HttpServletResponse response,
+			OAuth2TokenIntrospectionClaims tokenIntrospectionResponse) throws IOException {
+		ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
+		this.tokenIntrospectionHttpResponseConverter.write(tokenIntrospectionResponse, null, httpResponse);
+	}
+
+	private static void throwError(String errorCode, String parameterName) {
+		OAuth2Error error = new OAuth2Error(
+				errorCode, "OAuth 2.0 Token Introspection Parameter: " + parameterName,
+				"https://tools.ietf.org/html/rfc7662#section-2.1");
+		throw new OAuth2AuthenticationException(error);
+	}
+
+	private static class DefaultTokenIntrospectionAuthenticationConverter
+			implements Converter<HttpServletRequest, Authentication> {
+
+		@Override
+		public Authentication convert(HttpServletRequest request) {
+			Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication();
+
+			MultiValueMap<String, String> parameters = OAuth2EndpointUtils.getParameters(request);
+
+			// token (REQUIRED)
+			String token = parameters.getFirst(OAuth2ParameterNames2.TOKEN);
+			if (!StringUtils.hasText(token) || parameters.get(OAuth2ParameterNames2.TOKEN).size() != 1) {
+				throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames2.TOKEN);
+			}
+
+			// token_type_hint (OPTIONAL)
+			String tokenTypeHint = parameters.getFirst(OAuth2ParameterNames2.TOKEN_TYPE_HINT);
+			if (StringUtils.hasText(tokenTypeHint)
+					&& parameters.get(OAuth2ParameterNames2.TOKEN_TYPE_HINT).size() != 1) {
+				throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames2.TOKEN_TYPE_HINT);
+			}
+
+			return new OAuth2TokenIntrospectionAuthenticationToken(token, clientPrincipal, tokenTypeHint);
+		}
+	}
+}

+ 229 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenIntrospectionTests.java

@@ -0,0 +1,229 @@
+/*
+ * Copyright 2020-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.config.annotation.web.configurers.oauth2.server.authorization;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.config.test.SpringTestRule;
+import org.springframework.security.oauth2.core.AbstractOAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames2;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.web.OAuth2TokenIntrospectionEndpointFilter;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashSet;
+
+/**
+ * Integration tests for the OAuth 2.0 Token Introspection endpoint.
+ *
+ * @author Gerardo Roza
+ */
+public class OAuth2TokenIntrospectionTests {
+	private static RegisteredClientRepository registeredClientRepository;
+	private static OAuth2AuthorizationService authorizationService;
+	private static JWKSource<SecurityContext> jwkSource;
+
+	@Rule
+	public final SpringTestRule spring = new SpringTestRule();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@BeforeClass
+	public static void init() {
+		registeredClientRepository = mock(RegisteredClientRepository.class);
+		authorizationService = mock(OAuth2AuthorizationService.class);
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+	}
+
+	@Before
+	public void setup() {
+		reset(registeredClientRepository);
+		reset(authorizationService);
+	}
+
+	@Test
+	public void requestWhenIntrospectValidRefreshTokenThenActiveResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2RefreshToken token = authorization.getRefreshToken().getToken();
+		OAuth2TokenType tokenType = OAuth2TokenType.REFRESH_TOKEN;
+		when(authorizationService.findByToken(eq(token.getTokenValue()), isNull())).thenReturn(authorization);
+
+		// @formatter:off
+		this.mvc.perform(
+				MockMvcRequestBuilders.post(OAuth2TokenIntrospectionEndpointFilter.DEFAULT_TOKEN_INTROSPECTION_ENDPOINT_URI)
+						.params(getTokenIntrospectionRequestParameters(token, tokenType))
+						.with(httpBasic(registeredClient.getClientId(), registeredClient.getClientSecret())))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.active").value(true))
+				.andExpect(jsonPath("$.client_id").value("client-1"))
+				.andExpect(jsonPath("$.iat").isNotEmpty())
+				.andExpect(jsonPath("$.exp").isNotEmpty())
+				.andExpect(jsonPath("$.username").value("principal"));
+		// @formatter:on
+
+		verify(registeredClientRepository).findByClientId(eq(registeredClient.getClientId()));
+		verify(authorizationService).findByToken(eq(token.getTokenValue()), isNull());
+	}
+
+	@Test
+	public void requestWhenIntrospectValidAccessTokenThenActiveResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(Duration.ofHours(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(
+				OAuth2AccessToken.TokenType.BEARER, "token", issuedAt, expiresAt,
+				new HashSet<>(Arrays.asList("scope1", "Scope2")));
+		OAuth2TokenType tokenType = OAuth2TokenType.ACCESS_TOKEN;
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).token(accessToken)
+				.build();
+
+		when(authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull())).thenReturn(authorization);
+
+		// @formatter:off
+		this.mvc.perform(
+				MockMvcRequestBuilders.post(OAuth2TokenIntrospectionEndpointFilter.DEFAULT_TOKEN_INTROSPECTION_ENDPOINT_URI)
+						.params(getTokenIntrospectionRequestParameters(accessToken, tokenType))
+						.with(httpBasic(registeredClient.getClientId(), registeredClient.getClientSecret())))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.active").value(true))
+				.andExpect(jsonPath("$.client_id").value("client-1"))
+				.andExpect(jsonPath("$.scope").isNotEmpty())
+				.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.BEARER.getValue()))
+				.andExpect(jsonPath("$.iat").isNotEmpty())
+				.andExpect(jsonPath("$.exp").isNotEmpty())
+				.andExpect(jsonPath("$.username").value("principal"));
+		// @formatter:on
+
+		verify(registeredClientRepository).findByClientId(eq(registeredClient.getClientId()));
+		verify(authorizationService).findByToken(eq(accessToken.getTokenValue()), isNull());
+	}
+
+	@Test
+	public void requestWhenIntrospectTokenIssuedToDifferentClientThenActiveResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		when(registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+				.thenReturn(registeredClient);
+
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build();
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(Duration.ofHours(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(
+				OAuth2AccessToken.TokenType.BEARER, "token", issuedAt, expiresAt,
+				new HashSet<>(Arrays.asList("scope1", "Scope2")));
+		OAuth2TokenType tokenType = OAuth2TokenType.ACCESS_TOKEN;
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient2).token(accessToken)
+				.build();
+
+		when(authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull())).thenReturn(authorization);
+
+		// @formatter:off
+		this.mvc.perform(
+				MockMvcRequestBuilders.post(OAuth2TokenIntrospectionEndpointFilter.DEFAULT_TOKEN_INTROSPECTION_ENDPOINT_URI)
+						.params(getTokenIntrospectionRequestParameters(accessToken, tokenType))
+						.with(httpBasic(registeredClient.getClientId(), registeredClient.getClientSecret())))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.active").value(true))
+				.andExpect(jsonPath("$.client_id").value("client-1"))
+				.andExpect(jsonPath("$.scope").isNotEmpty())
+				.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.BEARER.getValue()))
+				.andExpect(jsonPath("$.iat").isNotEmpty())
+				.andExpect(jsonPath("$.exp").isNotEmpty())
+				.andExpect(jsonPath("$.username").value("principal"));
+		// @formatter:on
+
+		verify(registeredClientRepository).findByClientId(eq(registeredClient.getClientId()));
+		verify(authorizationService).findByToken(eq(accessToken.getTokenValue()), isNull());
+	}
+
+	private static MultiValueMap<String, String> getTokenIntrospectionRequestParameters(AbstractOAuth2Token token,
+			OAuth2TokenType tokenType) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames2.TOKEN, token.getTokenValue());
+		parameters.set(OAuth2ParameterNames2.TOKEN_TYPE_HINT, tokenType.getValue());
+		return parameters;
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository() {
+			return registeredClientRepository;
+		}
+
+		@Bean
+		OAuth2AuthorizationService authorizationService() {
+			return authorizationService;
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+	}
+}

+ 193 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverterTests.java

@@ -0,0 +1,193 @@
+/*
+ * Copyright 2020-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.core.http.converter;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor.ACTIVE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_ID;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.TOKEN_TYPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.USERNAME;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.AUD;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.EXP;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.IAT;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.ISS;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.JTI;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.NBF;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.SUB;
+
+import org.assertj.core.api.Condition;
+import org.junit.Test;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
+import org.springframework.security.oauth2.core.http.converter.OAuth2TokenIntrospectionClaimsHttpMessageConverter;
+import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaims;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Tests for {@link OAuth2TokenIntrospectionClaimsHttpMessageConverter}
+ *
+ * @author Gerardo Roza
+ */
+public class OAuth2TokenIntrospectionClaimsHttpMessageConverterTests {
+	private final OAuth2TokenIntrospectionClaimsHttpMessageConverter messageConverter = new OAuth2TokenIntrospectionClaimsHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOidcProviderConfigurationThenTrue() {
+		assertThat(this.messageConverter.supports(OAuth2TokenIntrospectionClaims.class)).isTrue();
+	}
+
+	@Test
+	public void setProviderConfigurationParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.messageConverter.setTokenIntrospectionResponseParametersConverter(null));
+	}
+
+	@Test
+	public void setProviderConfigurationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.messageConverter.setTokenIntrospectionResponseConverter(null));
+	}
+
+	@SuppressWarnings("unchecked")
+	@Test
+	public void readInternalWhenValidParametersThenSuccess() throws Exception {
+		// @formatter:off
+		String tokenIntrospectionResponseBody = "{\n"
+				+ "		\"active\": true,\n"
+				+ "		\"iss\": \"https://example.com/issuer1\",\n"
+				+ "		\"scope\": \"scope1 Scope2\",\n"
+				+ "		\"client_id\": \"clientId1\",\n"
+				+ "		\"token_type\": \"Bearer\",\n"
+				+ "		\"username\": \"username1\",\n"
+				+ "		\"aud\": [\"audience1\", \"audience2\"],\n"
+				+ "		\"exp\": 1607637467,\n"
+				+ "		\"iat\": 1607633867,\n"
+				+ "		\"nbf\": 1607633867,\n"
+				+ "		\"sub\": \"subject1\",\n"
+				+ "		\"jti\": \"jwtId1\"\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(
+				tokenIntrospectionResponseBody.getBytes(), HttpStatus.OK);
+		OAuth2TokenIntrospectionClaims tokenIntrospectionResponse = this.messageConverter
+				.readInternal(OAuth2TokenIntrospectionClaims.class, response);
+		Map<String, Object> responseParameters = tokenIntrospectionResponse.getClaims();
+		Condition<Object> collectionContainsCondition = new Condition<>(
+				collection -> Collection.class.isAssignableFrom(collection.getClass())
+						&& ((Collection<String>) collection).contains("audience1")
+						&& ((Collection<String>) collection).contains("audience2"),
+				"collection contains entries");
+
+		// @formatter:off
+		assertThat(responseParameters)
+			.containsEntry(ACTIVE, true)
+			.containsEntry(SCOPE, "scope1 Scope2")
+			.containsEntry(CLIENT_ID, "clientId1")
+			.containsEntry(TOKEN_TYPE, "Bearer")
+			.containsEntry(USERNAME, "username1")
+			.hasEntrySatisfying(AUD, collectionContainsCondition)
+			.containsEntry(EXP, 1607637467L)
+			.containsEntry(IAT, 1607633867L)
+			.containsEntry(NBF, 1607633867L)
+			.containsEntry(ISS, "https://example.com/issuer1")
+			.containsEntry(JTI, "jwtId1")
+			.containsEntry(SUB, "subject1");
+		// @formatter:on
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setTokenIntrospectionResponseConverter(source -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+				.isThrownBy(() -> this.messageConverter.readInternal(OAuth2TokenIntrospectionClaims.class, response))
+				.withMessageContaining("An error occurred reading the Token Introspection Response")
+				.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void writeInternalWhenTokenIntrospectionResponseThenSuccess() {
+		// @formatter:off
+		OAuth2TokenIntrospectionClaims providerConfiguration = OAuth2TokenIntrospectionClaims.builder(true)
+				.issuer("https://example.com/issuer1")
+				.scope("scope1 Scope2")
+				.clientId("clientId1")
+				.tokenType(TokenType.BEARER)
+				.username("username1")
+				.audience(Arrays.asList("audience1", "audience2"))
+				.expirationTime(Instant.ofEpochSecond(1607637467))
+				.issuedAt(Instant.ofEpochSecond(1607633867))
+				.notBefore(Instant.ofEpochSecond(1607633867))
+				.jwtId("jwtId1")
+				.subject("subject1").build();
+		// @formatter:on
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		this.messageConverter.writeInternal(providerConfiguration, outputMessage);
+
+		String providerConfigurationResponse = outputMessage.getBodyAsString();
+		// @formatter:off
+		assertThat(providerConfigurationResponse)
+			.contains("\"iss\":\"https://example.com/issuer1\"")
+			.contains("\"active\":true")
+			.contains("\"scope\":\"scope1 Scope2\"")
+			.contains("\"client_id\":\"clientId1\"")
+			.contains("\"token_type\":\"Bearer\"")
+			.contains("\"username\":\"username1\"")
+			.contains("\"aud\":[\"audience1\",\"audience2\"]")
+			.contains("\"exp\":1607637467")
+			.contains("\"iat\":1607633867")
+			.contains("\"nbf\":1607633867")
+			.contains("\"jti\":\"jwtId1\"")
+			.contains("\"sub\":\"subject1\"");
+		// @formatter:on
+	}
+
+	@Test
+	public void writeInternalWhenWriteFailsThenThrowsException() {
+		String errorMessage = "this is not a valid converter";
+		Converter<OAuth2TokenIntrospectionClaims, Map<String, Object>> failingConverter = source -> {
+			throw new RuntimeException(errorMessage);
+		};
+		this.messageConverter.setTokenIntrospectionResponseParametersConverter(failingConverter);
+
+		OAuth2TokenIntrospectionClaims providerConfiguration = OAuth2TokenIntrospectionClaims.builder(true).build();
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		assertThatThrownBy(() -> this.messageConverter.writeInternal(providerConfiguration, outputMessage))
+				.isInstanceOf(HttpMessageNotWritableException.class)
+				.hasMessageContaining("An error occurred writing the Token Introspection Response")
+				.hasMessageContaining(errorMessage);
+	}
+}

+ 16 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java

@@ -245,6 +245,22 @@ public class InMemoryOAuth2AuthorizationServiceTests {
 		assertThat(authorization).isEqualTo(result);
 	}
 
+	@Test
+	public void findByTokenWhenWrongTokenTypeThenNotFound() {
+		OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+				.id(ID)
+				.principalName(PRINCIPAL_NAME)
+				.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+				.refreshToken(refreshToken)
+				.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(
+				refreshToken.getTokenValue(), OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(result).isNull();
+	}
+
 	@Test
 	public void findByTokenWhenTokenDoesNotExistThenNull() {
 		OAuth2Authorization result = this.authorizationService.findByToken(

+ 249 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProviderTests.java

@@ -0,0 +1,249 @@
+/*
+ * Copyright 2020-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.authorization.authentication;
+
+import static java.time.Duration.ofHours;
+import static java.time.Instant.now;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor.ACTIVE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_ID;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.TOKEN_TYPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.USERNAME;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.EXP;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.IAT;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+
+/**
+ * Tests for {@link OAuth2TokenIntrospectionAuthenticationProvider}.
+ *
+ * @author Gerardo Roza
+ */
+public class OAuth2TokenIntrospectionAuthenticationProviderTests {
+	private OAuth2AuthorizationService authorizationService;
+	private OAuth2TokenIntrospectionAuthenticationProvider authenticationProvider;
+
+	@Before
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OAuth2TokenIntrospectionAuthenticationProvider(this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenIntrospectionAuthenticationProvider(null))
+				.isInstanceOf(IllegalArgumentException.class).hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2TokenIntrospectionAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2TokenIntrospectionAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(
+				registeredClient.getClientId(), registeredClient.getClientSecret());
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				"token", clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError()).extracting("errorCode")
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), registeredClient.getClientSecret(), ClientAuthenticationMethod.BASIC,
+				null);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				"token", clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError()).extracting("errorCode")
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenInvalidOAuth2TokenTypeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				"token", clientPrincipal, "unsupportedOAuth2TokenType");
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+				.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.isTokenActive()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenTokenNotFoundThenAuthenticatedButTokenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				"token", clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+				.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.isTokenActive()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenInvalidatedTokenThenAuthenticatedButTokenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		authorization = OAuth2AuthenticationProviderUtils.invalidate(authorization, accessToken);
+		when(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+				.thenReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+				.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.isTokenActive()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenValidAccessTokenThenActiveWithScopes() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(
+				OAuth2AccessToken.TokenType.BEARER, "access-token", Instant.now(), Instant.now().plusSeconds(300),
+				new HashSet<>(Arrays.asList("scope1", "Scope2")));
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.accessToken(accessToken).build();
+		when(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+				.thenReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+				.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.isTokenActive()).isTrue();
+		assertThat(authenticationResult.getClaims()).containsEntry(ACTIVE, true)
+				.containsEntry(IAT, accessToken.getIssuedAt()).containsEntry(EXP, accessToken.getExpiresAt())
+				.containsEntry(TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER).containsEntry(CLIENT_ID, "client-1")
+				.containsEntry(USERNAME, "principal").containsKey(SCOPE)
+				.containsAllEntriesOf(authorization.getAccessToken().getClaims());
+
+	}
+
+	@Test
+	public void authenticateWhenValidRefreshTokenThenActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken();
+		when(this.authorizationService.findByToken(eq(refreshToken.getTokenValue()), isNull()))
+				.thenReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				refreshToken.getTokenValue(), clientPrincipal, OAuth2TokenType.REFRESH_TOKEN.getValue());
+
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+				.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.isTokenActive()).isTrue();
+		assertThat(authenticationResult.getClaims()).containsEntry(ACTIVE, true)
+				.containsEntry(IAT, refreshToken.getIssuedAt()).containsEntry(EXP, refreshToken.getExpiresAt())
+				.containsEntry(USERNAME, "principal").containsEntry(CLIENT_ID, "client-1").hasSize(5);
+	}
+
+	@Test
+	public void authenticateWhenExpiredTokenThenAuthenticatedButTokenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Instant expiresAt = Instant.now().minus(ofHours(1));
+		Instant issuedAt = expiresAt.minus(ofHours(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(
+				OAuth2AccessToken.TokenType.BEARER, "access-token", issuedAt, expiresAt);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).token(accessToken)
+				.build();
+		when(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+				.thenReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+				.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.isTokenActive()).isFalse();
+		assertThat(authenticationResult.getClaims()).isNull();
+	}
+
+	@Test
+	public void authenticateWhenInvalidNotBeforeClaimThenAuthenticatedButTokenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(ofHours(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(
+				OAuth2AccessToken.TokenType.BEARER, "access-token", issuedAt, expiresAt);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(
+						accessToken,
+						(metadata) -> metadata.put(
+								OAuth2Authorization.Token.CLAIMS_METADATA_NAME,
+								Collections.singletonMap(JwtClaimNames.NBF, now().plus(ofHours(1)))))
+				.build();
+		when(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+				.thenReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+				.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.isTokenActive()).isFalse();
+		assertThat(authenticationResult.getClaims()).isNull();
+	}
+}

+ 98 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationTokenTests.java

@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020-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.authorization.authentication;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.Test;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Tests for {@link OAuth2TokenIntrospectionAuthenticationToken}.
+ *
+ * @author Gerardo Roza
+ */
+public class OAuth2TokenIntrospectionAuthenticationTokenTests {
+	private String tokenValue = "tokenValue";
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+			TestRegisteredClients.registeredClient().build());
+	private String tokenTypeHint = OAuth2TokenType.ACCESS_TOKEN.getValue();
+	private Map<String, Object> claims = new HashMap<>();
+
+	@Test
+	public void constructorWhenTokenValueNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2TokenIntrospectionAuthenticationToken(null, this.clientPrincipal, this.tokenTypeHint))
+						.isInstanceOf(IllegalArgumentException.class).hasMessage("token cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2TokenIntrospectionAuthenticationToken(this.tokenValue, null, this.tokenTypeHint))
+						.isInstanceOf(IllegalArgumentException.class).hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenAndClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenIntrospectionAuthenticationToken(null, this.claims))
+				.isInstanceOf(IllegalArgumentException.class).hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenValueProvidedThenCreated() {
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				this.tokenValue, this.clientPrincipal, this.tokenTypeHint);
+		assertThat(authentication.getTokenValue()).isEqualTo(this.tokenValue);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getTokenTypeHint()).isEqualTo(this.tokenTypeHint);
+		assertThat(authentication.getClaims()).isNull();
+		assertThat(authentication.isTokenActive()).isFalse();
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.isAuthenticated()).isFalse();
+	}
+
+	@Test
+	public void constructorWhenTokenProvidedThenCreated() {
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				this.clientPrincipal, this.claims);
+		assertThat(authentication.getTokenValue()).isNull();
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getClaims()).isEqualTo(this.claims);
+		assertThat(authentication.isTokenActive()).isTrue();
+		assertThat(authentication.getTokenTypeHint()).isNull();
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+
+	@Test
+	public void constructorWhenNullTokenProvidedThenCreatedAsTokenNotActive() {
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				this.clientPrincipal, null);
+		assertThat(authentication.getTokenValue()).isNull();
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getClaims()).isNull();
+		assertThat(authentication.isTokenActive()).isFalse();
+		assertThat(authentication.getTokenTypeHint()).isNull();
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+}

+ 2 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020 the original author or authors.
+ * Copyright 2020-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.
@@ -66,7 +66,7 @@ public class ProviderSettingsTests {
 				.setting("name1", "value1")
 				.settings(settings -> settings.put("name2", "value2"));
 
-		assertThat(providerSettings.settings()).hasSize(6);
+		assertThat(providerSettings.settings()).hasSize(7);
 		assertThat(providerSettings.<String>setting("name1")).isEqualTo("value1");
 		assertThat(providerSettings.<String>setting("name2")).isEqualTo("value2");
 	}

+ 278 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java

@@ -0,0 +1,278 @@
+/*
+ * Copyright 2020-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.authorization.web;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimAccessor.ACTIVE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.CLIENT_ID;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.SCOPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames.TOKEN_TYPE;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames2.TOKEN;
+import static org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames2.TOKEN_TYPE_HINT;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.EXP;
+import static org.springframework.security.oauth2.jwt.JwtClaimNames.IAT;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.assertj.core.api.Condition;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaims;
+import org.springframework.security.oauth2.core.OAuth2TokenType;
+import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.oauth2.core.http.converter.OAuth2TokenIntrospectionClaimsHttpMessageConverter;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Tests for {@link OAuth2TokenIntrospectionEndpointFilter}.
+ *
+ * @author Gerardo Roza
+ */
+public class OAuth2TokenIntrospectionEndpointFilterTests {
+
+	private AuthenticationManager authenticationManager;
+	private OAuth2TokenIntrospectionEndpointFilter filter;
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter = new OAuth2ErrorHttpMessageConverter();
+	private final HttpMessageConverter<OAuth2TokenIntrospectionClaims> tokenIntrospectionHttpResponseConverter = new OAuth2TokenIntrospectionClaimsHttpMessageConverter();
+	private final Condition<Object> scopesMatchesInAnyOrder = new Condition<>(
+			scopes -> scopes.equals("scope1 Scope2") || scopes.equals("Scope2 scope1"), "scopes match");
+	private final String tokenValue = "token.123";
+
+	@Before
+	public void setUp() {
+		this.authenticationManager = mock(AuthenticationManager.class);
+		this.filter = new OAuth2TokenIntrospectionEndpointFilter(this.authenticationManager);
+	}
+
+	@After
+	public void cleanup() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenIntrospectionEndpointFilter(null))
+				.isInstanceOf(IllegalArgumentException.class).hasMessage("authenticationManager cannot be null");
+	}
+
+	@Test
+	public void doFilterWhenNotIntrospectionRequestThenNotProcessed() throws Exception {
+		String requestUri = "/path";
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
+	}
+
+	@Test
+	public void doFilterWhenIntrospectionRequestGetThenNotProcessed() throws Exception {
+		String requestUri = OAuth2TokenIntrospectionEndpointFilter.DEFAULT_TOKEN_INTROSPECTION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
+	}
+
+	@Test
+	public void doFilterWhenIntrospectionRequestMissingTokenParamThenInvalidRequestError() throws Exception {
+		MockHttpServletRequest request = createTokenIntrospectionRequest(
+				this.tokenValue, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		request.removeParameter(TOKEN);
+
+		doFilterWhenTokenIntrospectionRequestInvalidParameterThenError(
+				TOKEN, OAuth2ErrorCodes.INVALID_REQUEST, request);
+	}
+
+	@Test
+	public void doFilterWhenTokenRequestMultipleTokenParamThenInvalidRequestError() throws Exception {
+		MockHttpServletRequest request = createTokenIntrospectionRequest(
+				this.tokenValue, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		request.addParameter(TOKEN, "token.456");
+
+		doFilterWhenTokenIntrospectionRequestInvalidParameterThenError(
+				TOKEN, OAuth2ErrorCodes.INVALID_REQUEST, request);
+	}
+
+	@Test
+	public void doFilterWhenTokenRequestMultipleTokenTypeHintParamThenInvalidRequestError() throws Exception {
+		MockHttpServletRequest request = createTokenIntrospectionRequest(
+				this.tokenValue, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		request.addParameter(TOKEN_TYPE_HINT, OAuth2TokenType.REFRESH_TOKEN.getValue());
+
+		doFilterWhenTokenIntrospectionRequestInvalidParameterThenError(
+				TOKEN_TYPE_HINT, OAuth2ErrorCodes.INVALID_REQUEST, request);
+	}
+
+	@Test
+	public void doFilterWhenIntrospectWithNullClaimsThenNotActiveTokenOkReponse() throws Exception {
+		MockHttpServletRequest request = createTokenIntrospectionRequest(
+				this.tokenValue, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		Authentication clientPrincipal = setupSecurityContext();
+
+		OAuth2TokenIntrospectionAuthenticationToken tokenIntrospectionAuthentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				clientPrincipal, null);
+
+		when(this.authenticationManager.authenticate(any())).thenReturn(tokenIntrospectionAuthentication);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertNotActiveTokenResponse(response);
+	}
+
+	@Test
+	public void doFilterWhenIntrospectWithClaimsThenActiveTokenReponse() throws Exception {
+		MockHttpServletRequest request = createTokenIntrospectionRequest(
+				this.tokenValue, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		Authentication clientPrincipal = setupSecurityContext();
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(Duration.ofHours(1));
+		String clientId = "clientId";
+		Map<String, Object> tokenIntrospectionClaims = new HashMap<>();
+		tokenIntrospectionClaims.put(ACTIVE, true);
+		tokenIntrospectionClaims.put(CLIENT_ID, clientId);
+		tokenIntrospectionClaims.put(IAT, issuedAt);
+		tokenIntrospectionClaims.put(EXP, expiresAt);
+		tokenIntrospectionClaims.put(TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER);
+		tokenIntrospectionClaims.put(SCOPE, "scope1 Scope2");
+
+		OAuth2TokenIntrospectionAuthenticationToken tokenIntrospectionAuthentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				clientPrincipal, tokenIntrospectionClaims);
+
+		when(this.authenticationManager.authenticate(any())).thenReturn(tokenIntrospectionAuthentication);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+		OAuth2TokenIntrospectionClaims tokenIntrospectionResponse = readTokenIntrospectionResponse(response);
+
+		Map<String, Object> responseMap = tokenIntrospectionResponse.getClaims();
+		// @formatter:off
+		assertThat(responseMap).contains(
+				entry(ACTIVE, true),
+				entry(CLIENT_ID, clientId),
+				entry(TOKEN_TYPE, OAuth2AccessToken.TokenType.BEARER.getValue()),
+				entry(EXP, expiresAt.getEpochSecond()),
+				entry(IAT, issuedAt.getEpochSecond()))
+		.hasEntrySatisfying(SCOPE, scopesMatchesInAnyOrder)
+		.hasSize(6);
+		// @formatter: on
+	}
+
+	private void assertNotActiveTokenResponse(MockHttpServletResponse response) throws Exception {
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+		OAuth2TokenIntrospectionClaims tokenIntrospectionResponse = readTokenIntrospectionResponse(response);
+		assertThat(tokenIntrospectionResponse.getClaims()).containsEntry("active", false).hasSize(1);
+	}
+
+	private OAuth2Error readError(MockHttpServletResponse response) throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(),
+				HttpStatus.valueOf(response.getStatus()));
+		return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse);
+	}
+
+	private OAuth2TokenIntrospectionClaims readTokenIntrospectionResponse(MockHttpServletResponse response) throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(),
+				HttpStatus.valueOf(response.getStatus()));
+		return this.tokenIntrospectionHttpResponseConverter.read(OAuth2TokenIntrospectionClaims.class, httpResponse);
+	}
+
+	private void doFilterWhenTokenIntrospectionRequestInvalidParameterThenError(String parameterName, String errorCode,
+			MockHttpServletRequest request) throws Exception {
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		setupSecurityContext();
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(errorCode);
+		assertThat(error.getDescription()).isEqualTo("OAuth 2.0 Token Introspection Parameter: " + parameterName);
+	}
+
+	private static Authentication setupSecurityContext() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient);
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(clientPrincipal);
+		SecurityContextHolder.setContext(securityContext);
+		return clientPrincipal;
+	}
+
+	private static MockHttpServletRequest createTokenIntrospectionRequest(String token, String tokenTypeHint) {
+		String requestUri = OAuth2TokenIntrospectionEndpointFilter.DEFAULT_TOKEN_INTROSPECTION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+
+		request.addParameter(TOKEN, token);
+		request.addParameter(TOKEN_TYPE_HINT, tokenTypeHint);
+		return request;
+
+}
+}