From 4204bc7e78aa9d93ba7fc81ad65034dcd16b6663 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Tue, 22 Jun 2021 18:54:21 -0500 Subject: [PATCH] Temporarily fix expires_in for access token response TODO: This can be reverted when Spring Security 5.6 is released. Closes gh-281 --- ...cessTokenResponseHttpMessageConverter.java | 376 ++++++++++++++++++ .../web/OAuth2TokenEndpointFilter.java | 1 - .../web/OAuth2TokenEndpointFilterTests.java | 5 +- 3 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AccessTokenResponseHttpMessageConverter.java diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AccessTokenResponseHttpMessageConverter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AccessTokenResponseHttpMessageConverter.java new file mode 100644 index 00000000..84b5ac5a --- /dev/null +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AccessTokenResponseHttpMessageConverter.java @@ -0,0 +1,376 @@ +/* + * Copyright 2002-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.oauth2.server.authorization.web; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.core.ParameterizedTypeReference; +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.http.converter.json.GsonHttpMessageConverter; +import org.springframework.http.converter.json.JsonbHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.endpoint.MapOAuth2AccessTokenResponseConverter; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponseMapConverter; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * TODO + * This class is a copy from Spring Security and should be removed after upgrading to Spring Security 5.6.0 GA. + * + * A {@link HttpMessageConverter} for an {@link OAuth2AccessTokenResponse OAuth 2.0 Access + * Token Response}. + * + * @author Joe Grandja + * @since 5.1 + * @see AbstractHttpMessageConverter + * @see OAuth2AccessTokenResponse + */ +class OAuth2AccessTokenResponseHttpMessageConverter + extends AbstractHttpMessageConverter { + + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference>() { + }; + + private GenericHttpMessageConverter jsonMessageConverter = HttpMessageConverters.getJsonMessageConverter(); + + /** + * @deprecated This field should no longer be used + */ + @Deprecated + protected Converter, OAuth2AccessTokenResponse> tokenResponseConverter = new MapOAuth2AccessTokenResponseConverter(); + + private Converter, OAuth2AccessTokenResponse> accessTokenResponseConverter = new DefaultMapOAuth2AccessTokenResponseConverter(); + + /** + * @deprecated This field should no longer be used + */ + @Deprecated + protected Converter> tokenResponseParametersConverter = new OAuth2AccessTokenResponseMapConverter(); + + private Converter> accessTokenResponseParametersConverter = new DefaultOAuth2AccessTokenResponseMapConverter(); + + OAuth2AccessTokenResponseHttpMessageConverter() { + super(DEFAULT_CHARSET, MediaType.APPLICATION_JSON, new MediaType("application", "*+json")); + } + + @Override + protected boolean supports(Class clazz) { + return OAuth2AccessTokenResponse.class.isAssignableFrom(clazz); + } + + @Override + @SuppressWarnings("unchecked") + protected OAuth2AccessTokenResponse readInternal(Class clazz, + HttpInputMessage inputMessage) throws HttpMessageNotReadableException { + try { + Map tokenResponseParameters = (Map) this.jsonMessageConverter + .read(STRING_OBJECT_MAP.getType(), null, inputMessage); + // Only use deprecated converter if it has been set directly + if (this.tokenResponseConverter.getClass() != MapOAuth2AccessTokenResponseConverter.class) { + // gh-6463: Parse parameter values as Object in order to handle potential + // JSON Object and then convert values to String + Map stringTokenResponseParameters = new HashMap<>(); + tokenResponseParameters + .forEach((key, value) -> stringTokenResponseParameters.put(key, String.valueOf(value))); + return this.tokenResponseConverter.convert(stringTokenResponseParameters); + } + return this.accessTokenResponseConverter.convert(tokenResponseParameters); + } + catch (Exception ex) { + throw new HttpMessageNotReadableException( + "An error occurred reading the OAuth 2.0 Access Token Response: " + ex.getMessage(), ex, + inputMessage); + } + } + + @Override + protected void writeInternal(OAuth2AccessTokenResponse tokenResponse, HttpOutputMessage outputMessage) + throws HttpMessageNotWritableException { + try { + Map tokenResponseParameters; + // Only use deprecated converter if it has been set directly + if (this.tokenResponseParametersConverter.getClass() != OAuth2AccessTokenResponseMapConverter.class) { + tokenResponseParameters = new LinkedHashMap<>( + this.tokenResponseParametersConverter.convert(tokenResponse)); + } + else { + tokenResponseParameters = this.accessTokenResponseParametersConverter.convert(tokenResponse); + } + this.jsonMessageConverter.write(tokenResponseParameters, STRING_OBJECT_MAP.getType(), + MediaType.APPLICATION_JSON, outputMessage); + } + catch (Exception ex) { + throw new HttpMessageNotWritableException( + "An error occurred writing the OAuth 2.0 Access Token Response: " + ex.getMessage(), ex); + } + } + + /** + * Sets the {@link Converter} used for converting the OAuth 2.0 Access Token Response + * parameters to an {@link OAuth2AccessTokenResponse}. + * @deprecated Use {@link #setAccessTokenResponseConverter(Converter)} instead + * @param tokenResponseConverter the {@link Converter} used for converting to an + * {@link OAuth2AccessTokenResponse} + */ + @Deprecated + public final void setTokenResponseConverter( + Converter, OAuth2AccessTokenResponse> tokenResponseConverter) { + Assert.notNull(tokenResponseConverter, "tokenResponseConverter cannot be null"); + this.tokenResponseConverter = tokenResponseConverter; + } + + /** + * Sets the {@link Converter} used for converting the OAuth 2.0 Access Token Response + * parameters to an {@link OAuth2AccessTokenResponse}. + * @param accessTokenResponseConverter the {@link Converter} used for converting to an + * {@link OAuth2AccessTokenResponse} + * @since 5.6 + */ + public final void setAccessTokenResponseConverter( + Converter, OAuth2AccessTokenResponse> accessTokenResponseConverter) { + Assert.notNull(accessTokenResponseConverter, "accessTokenResponseConverter cannot be null"); + this.accessTokenResponseConverter = accessTokenResponseConverter; + } + + /** + * Sets the {@link Converter} used for converting the + * {@link OAuth2AccessTokenResponse} to a {@code Map} representation of the OAuth 2.0 + * Access Token Response parameters. + * @deprecated Use {@link #setAccessTokenResponseParametersConverter(Converter)} + * instead + * @param tokenResponseParametersConverter the {@link Converter} used for converting + * to a {@code Map} representation of the Access Token Response parameters + */ + @Deprecated + public final void setTokenResponseParametersConverter( + Converter> tokenResponseParametersConverter) { + Assert.notNull(tokenResponseParametersConverter, "tokenResponseParametersConverter cannot be null"); + this.tokenResponseParametersConverter = tokenResponseParametersConverter; + } + + /** + * Sets the {@link Converter} used for converting the + * {@link OAuth2AccessTokenResponse} to a {@code Map} representation of the OAuth 2.0 + * Access Token Response parameters. + * @param accessTokenResponseParametersConverter the {@link Converter} used for + * converting to a {@code Map} representation of the Access Token Response parameters + * @since 5.6 + */ + public final void setAccessTokenResponseParametersConverter( + Converter> accessTokenResponseParametersConverter) { + Assert.notNull(accessTokenResponseParametersConverter, "accessTokenResponseParametersConverter cannot be null"); + this.accessTokenResponseParametersConverter = accessTokenResponseParametersConverter; + } + + /** + * Utility methods for {@link HttpMessageConverter}'s. + * + * @author Joe Grandja + * @since 5.1 + */ + static final class HttpMessageConverters { + + private static final boolean jackson2Present; + + private static final boolean gsonPresent; + + private static final boolean jsonbPresent; + + static { + ClassLoader classLoader = HttpMessageConverters.class.getClassLoader(); + jackson2Present = ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper", classLoader) + && ClassUtils.isPresent("com.fasterxml.jackson.core.JsonGenerator", classLoader); + gsonPresent = ClassUtils.isPresent("com.google.gson.Gson", classLoader); + jsonbPresent = ClassUtils.isPresent("javax.json.bind.Jsonb", classLoader); + } + + private HttpMessageConverters() { + } + + static GenericHttpMessageConverter getJsonMessageConverter() { + if (jackson2Present) { + return new MappingJackson2HttpMessageConverter(); + } + if (gsonPresent) { + return new GsonHttpMessageConverter(); + } + if (jsonbPresent) { + return new JsonbHttpMessageConverter(); + } + return null; + } + + } + + /** + * A {@link Converter} that converts the provided OAuth 2.0 Access Token Response + * parameters to an {@link OAuth2AccessTokenResponse}. + * + * @author Steve Riesenberg + * @since 5.6 + */ + static final class DefaultMapOAuth2AccessTokenResponseConverter + implements Converter, OAuth2AccessTokenResponse> { + + private static final Set TOKEN_RESPONSE_PARAMETER_NAMES = new HashSet<>( + Arrays.asList(OAuth2ParameterNames.ACCESS_TOKEN, OAuth2ParameterNames.EXPIRES_IN, + OAuth2ParameterNames.REFRESH_TOKEN, OAuth2ParameterNames.SCOPE, OAuth2ParameterNames.TOKEN_TYPE)); + + @Override + public OAuth2AccessTokenResponse convert(Map source) { + String accessToken = getParameterValue(source, OAuth2ParameterNames.ACCESS_TOKEN); + OAuth2AccessToken.TokenType accessTokenType = getAccessTokenType(source); + long expiresIn = getExpiresIn(source); + Set scopes = getScopes(source); + String refreshToken = getParameterValue(source, OAuth2ParameterNames.REFRESH_TOKEN); + Map additionalParameters = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + if (!TOKEN_RESPONSE_PARAMETER_NAMES.contains(entry.getKey())) { + additionalParameters.put(entry.getKey(), entry.getValue()); + } + } + // @formatter:off + return OAuth2AccessTokenResponse.withToken(accessToken) + .tokenType(accessTokenType) + .expiresIn(expiresIn) + .scopes(scopes) + .refreshToken(refreshToken) + .additionalParameters(additionalParameters) + .build(); + // @formatter:on + } + + private static OAuth2AccessToken.TokenType getAccessTokenType(Map tokenResponseParameters) { + if (OAuth2AccessToken.TokenType.BEARER.getValue() + .equalsIgnoreCase(getParameterValue(tokenResponseParameters, OAuth2ParameterNames.TOKEN_TYPE))) { + return OAuth2AccessToken.TokenType.BEARER; + } + return null; + } + + private static long getExpiresIn(Map tokenResponseParameters) { + return getParameterValue(tokenResponseParameters, OAuth2ParameterNames.EXPIRES_IN, 0L); + } + + private static Set getScopes(Map tokenResponseParameters) { + if (tokenResponseParameters.containsKey(OAuth2ParameterNames.SCOPE)) { + String scope = getParameterValue(tokenResponseParameters, OAuth2ParameterNames.SCOPE); + return new HashSet<>(Arrays.asList(StringUtils.delimitedListToStringArray(scope, " "))); + } + return Collections.emptySet(); + } + + private static String getParameterValue(Map tokenResponseParameters, String parameterName) { + Object obj = tokenResponseParameters.get(parameterName); + return (obj != null) ? obj.toString() : null; + } + + private static long getParameterValue(Map tokenResponseParameters, String parameterName, + long defaultValue) { + long parameterValue = defaultValue; + + Object obj = tokenResponseParameters.get(parameterName); + if (obj != null) { + // Final classes Long and Integer do not need to be coerced + if (obj.getClass() == Long.class) { + parameterValue = (Long) obj; + } + else if (obj.getClass() == Integer.class) { + parameterValue = (Integer) obj; + } + else { + // Attempt to coerce to a long (typically from a String) + try { + parameterValue = Long.parseLong(obj.toString()); + } + catch (NumberFormatException ignored) { + } + } + } + + return parameterValue; + } + + } + + /** + * A {@link Converter} that converts the provided {@link OAuth2AccessTokenResponse} to a + * {@code Map} representation of the OAuth 2.0 Access Token Response parameters. + * + * @author Steve Riesenberg + * @since 5.6 + */ + static final class DefaultOAuth2AccessTokenResponseMapConverter + implements Converter> { + + @Override + public Map convert(OAuth2AccessTokenResponse tokenResponse) { + Map parameters = new HashMap<>(); + parameters.put(OAuth2ParameterNames.ACCESS_TOKEN, tokenResponse.getAccessToken().getTokenValue()); + parameters.put(OAuth2ParameterNames.TOKEN_TYPE, tokenResponse.getAccessToken().getTokenType().getValue()); + parameters.put(OAuth2ParameterNames.EXPIRES_IN, getExpiresIn(tokenResponse)); + if (!CollectionUtils.isEmpty(tokenResponse.getAccessToken().getScopes())) { + parameters.put(OAuth2ParameterNames.SCOPE, + StringUtils.collectionToDelimitedString(tokenResponse.getAccessToken().getScopes(), " ")); + } + if (tokenResponse.getRefreshToken() != null) { + parameters.put(OAuth2ParameterNames.REFRESH_TOKEN, tokenResponse.getRefreshToken().getTokenValue()); + } + if (!CollectionUtils.isEmpty(tokenResponse.getAdditionalParameters())) { + for (Map.Entry entry : tokenResponse.getAdditionalParameters().entrySet()) { + parameters.put(entry.getKey(), entry.getValue()); + } + } + return parameters; + } + + private static long getExpiresIn(OAuth2AccessTokenResponse tokenResponse) { + if (tokenResponse.getAccessToken().getExpiresAt() != null) { + return ChronoUnit.SECONDS.between(Instant.now(), tokenResponse.getAccessToken().getExpiresAt()); + } + return -1; + } + + } + +} diff --git a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java index 5500e18f..3fab34f5 100644 --- a/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java +++ b/oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilter.java @@ -42,7 +42,6 @@ import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; -import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider; diff --git a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java index 6654488a..e56c61b6 100644 --- a/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java +++ b/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2TokenEndpointFilterTests.java @@ -26,6 +26,7 @@ import javax.servlet.FilterChain; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -48,7 +49,6 @@ import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.OAuth2RefreshToken2; import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; -import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter; import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken; import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken; @@ -335,6 +335,9 @@ public class OAuth2TokenEndpointFilterTests { .isEqualTo(REMOTE_ADDRESS); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value()); + // For gh-281, check that expires_in is a number + assertThat(new ObjectMapper().readValue(response.getContentAsByteArray(), Map.class).get(OAuth2ParameterNames.EXPIRES_IN)) + .isInstanceOf(Number.class); OAuth2AccessTokenResponse accessTokenResponse = readAccessTokenResponse(response); OAuth2AccessToken accessTokenResult = accessTokenResponse.getAccessToken();