diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java b/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java index 353c2c31..28028414 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2AuthorizationServerConfigurer.java +++ b/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 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 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 OAuth2AuthorizationServerConfigurerIntrospection Response + */ +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 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); + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaims.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/OAuth2TokenIntrospectionClaims.java new file mode 100644 index 00000000..8fb29048 --- /dev/null +++ b/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 Section 2.2 - Introspection Response + */ +public class OAuth2TokenIntrospectionClaims implements OAuth2TokenIntrospectionClaimAccessor, Serializable { + private static final long serialVersionUID = Version.SERIAL_VERSION_UID; + private static final Collection SUPPORTED_FIELDS = Arrays + .asList(ACTIVE, CLIENT_ID, SCOPE, TOKEN_TYPE, USERNAME, AUD, EXP, IAT, ISS, JTI, NBF, SUB); + + private final Map claims; + + private OAuth2TokenIntrospectionClaims(Map claims) { + this.claims = Collections.unmodifiableMap(new LinkedHashMap<>(claims)); + } + + /** + * Returns the populated Token Introspection Response claims. + * + * @return the claims + */ + @Override + public Map 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 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 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 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> 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 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); + } + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverter.java new file mode 100644 index 00000000..451d7b5f --- /dev/null +++ b/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 { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference>() { + }; + + private GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter(); + + private Converter, OAuth2TokenIntrospectionClaims> tokenIntrospectionResponseConverter = new OAuth2TokenIntrospectionResponseConverter(); + private Converter> 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 clazz, + HttpInputMessage inputMessage) throws HttpMessageNotReadableException { + try { + Map tokenIntrospectionResponseParameters = (Map) 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 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, 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> tokenIntrospectionResponseParametersConverter) { + Assert.notNull( + tokenIntrospectionResponseParametersConverter, + "tokenIntrospectionResponseParametersConverter cannot be null"); + this.tokenIntrospectionResponseParametersConverter = tokenIntrospectionResponseParametersConverter; + } + + private static final class OAuth2TokenIntrospectionResponseConverter + implements Converter, 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 collectionStringConverter = getConverter( + TypeDescriptor.collection(Collection.class, STRING_TYPE_DESCRIPTOR)); + Converter stringConverter = getConverter(STRING_TYPE_DESCRIPTOR); + Converter booleanConverter = getConverter(BOOLEAN_TYPE_DESCRIPTOR); + Converter instantConverter = getConverter(INSTANT_TYPE_DESCRIPTOR); + + Map> 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 source) { + Map parsedClaims = this.parameterTypeConverter.convert(source); + return OAuth2TokenIntrospectionClaims.withClaims(parsedClaims).build(); + } + + private static Converter getConverter(TypeDescriptor targetDescriptor) { + return (source) -> CLAIM_CONVERSION_SERVICE.convert(source, OBJECT_TYPE_DESCRIPTOR, targetDescriptor); + } + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProvider.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProvider.java new file mode 100644 index 00000000..7ef50eae --- /dev/null +++ b/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 Section 2.1 - Introspection Request + */ +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 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 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 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 generateTokenIntrospectionClaims(Token tokenHolder, + String clientId, String username) { + Map 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 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); + } +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationToken.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationToken.java new file mode 100644 index 00000000..1ce35da3 --- /dev/null +++ b/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 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 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 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; + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java index 7551102c..12c7d498 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettings.java +++ b/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 defaultSettings() { Map 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; } } diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilter.java new file mode 100644 index 00000000..4d4e4a1a --- /dev/null +++ b/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 Section 2 - Introspection Endpoint + * @see Section 2.1 - Introspection Request + * @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 tokenIntrospectionAuthenticationConverter = new DefaultTokenIntrospectionAuthenticationConverter(); + private final HttpMessageConverter tokenIntrospectionHttpResponseConverter = new OAuth2TokenIntrospectionClaimsHttpMessageConverter(); + + private final HttpMessageConverter 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 { + + @Override + public Authentication convert(HttpServletRequest request) { + Authentication clientPrincipal = SecurityContextHolder.getContext().getAuthentication(); + + MultiValueMap 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); + } + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenIntrospectionTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/authorization/OAuth2TokenIntrospectionTests.java new file mode 100644 index 00000000..eb7533ad --- /dev/null +++ b/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 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 getTokenIntrospectionRequestParameters(AbstractOAuth2Token token, + OAuth2TokenType tokenType) { + MultiValueMap 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 jwkSource() { + return jwkSource; + } + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2TokenIntrospectionClaimsHttpMessageConverterTests.java new file mode 100644 index 00000000..ad3aea60 --- /dev/null +++ b/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 responseParameters = tokenIntrospectionResponse.getClaims(); + Condition collectionContainsCondition = new Condition<>( + collection -> Collection.class.isAssignableFrom(collection.getClass()) + && ((Collection) collection).contains("audience1") + && ((Collection) 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> 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); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java index 784cdfad..1913985c 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java +++ b/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( diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProviderTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProviderTests.java new file mode 100644 index 00000000..c4225d1a --- /dev/null +++ b/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(); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationTokenTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationTokenTests.java new file mode 100644 index 00000000..7488904e --- /dev/null +++ b/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 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(); + } +} diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java index db8a3a1c..97fcf02a 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/ProviderSettingsTests.java +++ b/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.setting("name1")).isEqualTo("value1"); assertThat(providerSettings.setting("name2")).isEqualTo("value2"); } diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenIntrospectionEndpointFilterTests.java new file mode 100644 index 00000000..3c68d74b --- /dev/null +++ b/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 errorHttpResponseConverter = new OAuth2ErrorHttpMessageConverter(); + private final HttpMessageConverter tokenIntrospectionHttpResponseConverter = new OAuth2TokenIntrospectionClaimsHttpMessageConverter(); + private final Condition 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 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 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; + +} +}