OAuth2DeviceAuthorizationResponseHttpMessageConverter.java

/*
 * Copyright 2004-present 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 java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
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.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

/**
 * A {@link HttpMessageConverter} for an {@link OAuth2DeviceAuthorizationResponse OAuth
 * 2.0 Device Authorization Response}.
 *
 * @author Steve Riesenberg
 * @since 6.1
 * @see AbstractHttpMessageConverter
 * @see OAuth2DeviceAuthorizationResponse
 */
public class OAuth2DeviceAuthorizationResponseHttpMessageConverter
		extends AbstractHttpMessageConverter<OAuth2DeviceAuthorizationResponse> {

	private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() {
	};

	private final GenericHttpMessageConverter<Object> jsonMessageConverter = HttpMessageConverters
		.getJsonMessageConverter();

	private Converter<Map<String, Object>, OAuth2DeviceAuthorizationResponse> deviceAuthorizationResponseConverter = new DefaultMapOAuth2DeviceAuthorizationResponseConverter();

	private Converter<OAuth2DeviceAuthorizationResponse, Map<String, Object>> deviceAuthorizationResponseParametersConverter = new DefaultOAuth2DeviceAuthorizationResponseMapConverter();

	@Override
	protected boolean supports(Class<?> clazz) {
		return OAuth2DeviceAuthorizationResponse.class.isAssignableFrom(clazz);
	}

	@Override
	@SuppressWarnings("unchecked")
	protected OAuth2DeviceAuthorizationResponse readInternal(Class<? extends OAuth2DeviceAuthorizationResponse> clazz,
			HttpInputMessage inputMessage) throws HttpMessageNotReadableException {

		try {
			Map<String, Object> deviceAuthorizationResponseParameters = (Map<String, Object>) this.jsonMessageConverter
				.read(STRING_OBJECT_MAP.getType(), null, inputMessage);
			return this.deviceAuthorizationResponseConverter.convert(deviceAuthorizationResponseParameters);
		}
		catch (Exception ex) {
			throw new HttpMessageNotReadableException(
					"An error occurred reading the OAuth 2.0 Device Authorization Response: " + ex.getMessage(), ex,
					inputMessage);
		}
	}

	@Override
	protected void writeInternal(OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse,
			HttpOutputMessage outputMessage) throws HttpMessageNotWritableException {

		try {
			Map<String, Object> deviceAuthorizationResponseParameters = this.deviceAuthorizationResponseParametersConverter
				.convert(deviceAuthorizationResponse);
			this.jsonMessageConverter.write(deviceAuthorizationResponseParameters, STRING_OBJECT_MAP.getType(),
					MediaType.APPLICATION_JSON, outputMessage);
		}
		catch (Exception ex) {
			throw new HttpMessageNotWritableException(
					"An error occurred writing the OAuth 2.0 Device Authorization Response: " + ex.getMessage(), ex);
		}
	}

	/**
	 * Sets the {@link Converter} used for converting the OAuth 2.0 Device Authorization
	 * Response parameters to an {@link OAuth2DeviceAuthorizationResponse}.
	 * @param deviceAuthorizationResponseConverter the {@link Converter} used for
	 * converting to an {@link OAuth2DeviceAuthorizationResponse}
	 */
	public final void setDeviceAuthorizationResponseConverter(
			Converter<Map<String, Object>, OAuth2DeviceAuthorizationResponse> deviceAuthorizationResponseConverter) {
		Assert.notNull(deviceAuthorizationResponseConverter, "deviceAuthorizationResponseConverter cannot be null");
		this.deviceAuthorizationResponseConverter = deviceAuthorizationResponseConverter;
	}

	/**
	 * Sets the {@link Converter} used for converting the
	 * {@link OAuth2DeviceAuthorizationResponse} to a {@code Map} representation of the
	 * OAuth 2.0 Device Authorization Response parameters.
	 * @param deviceAuthorizationResponseParametersConverter the {@link Converter} used
	 * for converting to a {@code Map} representation of the Device Authorization Response
	 * parameters
	 */
	public final void setDeviceAuthorizationResponseParametersConverter(
			Converter<OAuth2DeviceAuthorizationResponse, Map<String, Object>> deviceAuthorizationResponseParametersConverter) {
		Assert.notNull(deviceAuthorizationResponseParametersConverter,
				"deviceAuthorizationResponseParametersConverter cannot be null");
		this.deviceAuthorizationResponseParametersConverter = deviceAuthorizationResponseParametersConverter;
	}

	private static final class DefaultMapOAuth2DeviceAuthorizationResponseConverter
			implements Converter<Map<String, Object>, OAuth2DeviceAuthorizationResponse> {

		private static final Set<String> DEVICE_AUTHORIZATION_RESPONSE_PARAMETER_NAMES = new HashSet<>(
				Arrays.asList(OAuth2ParameterNames.DEVICE_CODE, OAuth2ParameterNames.USER_CODE,
						OAuth2ParameterNames.VERIFICATION_URI, OAuth2ParameterNames.VERIFICATION_URI_COMPLETE,
						OAuth2ParameterNames.EXPIRES_IN, OAuth2ParameterNames.INTERVAL));

		@Override
		public OAuth2DeviceAuthorizationResponse convert(Map<String, Object> parameters) {
			String deviceCode = getParameterValue(parameters, OAuth2ParameterNames.DEVICE_CODE);
			String userCode = getParameterValue(parameters, OAuth2ParameterNames.USER_CODE);
			String verificationUri = getParameterValue(parameters, OAuth2ParameterNames.VERIFICATION_URI);
			String verificationUriComplete = getParameterValue(parameters,
					OAuth2ParameterNames.VERIFICATION_URI_COMPLETE);
			long expiresIn = getParameterValue(parameters, OAuth2ParameterNames.EXPIRES_IN, 0L);
			long interval = getParameterValue(parameters, OAuth2ParameterNames.INTERVAL, 0L);
			Map<String, Object> additionalParameters = new LinkedHashMap<>();
			parameters.forEach((key, value) -> {
				if (!DEVICE_AUTHORIZATION_RESPONSE_PARAMETER_NAMES.contains(key)) {
					additionalParameters.put(key, value);
				}
			});
			// @formatter:off
			return OAuth2DeviceAuthorizationResponse.with(deviceCode, userCode)
					.verificationUri(verificationUri)
					.verificationUriComplete(verificationUriComplete)
					.expiresIn(expiresIn)
					.interval(interval)
					.additionalParameters(additionalParameters)
					.build();
			// @formatter:on
		}

		private static String getParameterValue(Map<String, Object> parameters, String parameterName) {
			Object obj = parameters.get(parameterName);
			return (obj != null) ? obj.toString() : null;
		}

		private static long getParameterValue(Map<String, Object> parameters, String parameterName, long defaultValue) {
			long parameterValue = defaultValue;

			Object obj = parameters.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;
		}

	}

	private static final class DefaultOAuth2DeviceAuthorizationResponseMapConverter
			implements Converter<OAuth2DeviceAuthorizationResponse, Map<String, Object>> {

		@Override
		public Map<String, Object> convert(OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse) {
			Map<String, Object> parameters = new HashMap<>();
			parameters.put(OAuth2ParameterNames.DEVICE_CODE,
					deviceAuthorizationResponse.getDeviceCode().getTokenValue());
			parameters.put(OAuth2ParameterNames.USER_CODE, deviceAuthorizationResponse.getUserCode().getTokenValue());
			parameters.put(OAuth2ParameterNames.VERIFICATION_URI, deviceAuthorizationResponse.getVerificationUri());
			if (StringUtils.hasText(deviceAuthorizationResponse.getVerificationUriComplete())) {
				parameters.put(OAuth2ParameterNames.VERIFICATION_URI_COMPLETE,
						deviceAuthorizationResponse.getVerificationUriComplete());
			}
			parameters.put(OAuth2ParameterNames.EXPIRES_IN, getExpiresIn(deviceAuthorizationResponse));
			if (deviceAuthorizationResponse.getInterval() > 0) {
				parameters.put(OAuth2ParameterNames.INTERVAL, deviceAuthorizationResponse.getInterval());
			}
			if (!CollectionUtils.isEmpty(deviceAuthorizationResponse.getAdditionalParameters())) {
				parameters.putAll(deviceAuthorizationResponse.getAdditionalParameters());
			}
			return parameters;
		}

		private static long getExpiresIn(OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse) {
			if (deviceAuthorizationResponse.getDeviceCode().getExpiresAt() != null) {
				Instant issuedAt = (deviceAuthorizationResponse.getDeviceCode().getIssuedAt() != null)
						? deviceAuthorizationResponse.getDeviceCode().getIssuedAt() : Instant.now();
				return ChronoUnit.SECONDS.between(issuedAt, deviceAuthorizationResponse.getDeviceCode().getExpiresAt());
			}
			return -1;
		}

	}

}