Browse Source

Add support for device authorization response

Closes gh-12852
Steve Riesenberg 2 years ago
parent
commit
8c17b978c8

+ 8 - 1
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2023 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 @@ import org.springframework.util.Assert;
  * extensibility mechanism for defining additional grant types.
  *
  * @author Joe Grandja
+ * @author Steve Riesenberg
  * @since 5.0
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-1.3">Section
  * 1.3 Authorization Grant</a>
@@ -62,6 +63,12 @@ public final class AuthorizationGrantType implements Serializable {
 	public static final AuthorizationGrantType JWT_BEARER = new AuthorizationGrantType(
 			"urn:ietf:params:oauth:grant-type:jwt-bearer");
 
+	/**
+	 * @since 6.1
+	 */
+	public static final AuthorizationGrantType DEVICE_CODE = new AuthorizationGrantType(
+			"urn:ietf:params:oauth:grant-type:device_code");
+
 	private final String value;
 
 	/**

+ 43 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2DeviceCode.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2002-2023 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 java.time.Instant;
+
+/**
+ * An implementation of an {@link AbstractOAuth2Token} representing a device code as part
+ * of the OAuth 2.0 Device Authorization Grant.
+ *
+ * @author Steve Riesenberg
+ * @since 6.1
+ * @see OAuth2UserCode
+ * @see <a target="_blank" href= "https://tools.ietf.org/html/rfc8628#section-3.2">Section
+ * 3.2 Device Authorization Response</a>
+ */
+public final class OAuth2DeviceCode extends AbstractOAuth2Token {
+
+	/**
+	 * Constructs an {@code OAuth2DeviceCode} using the provided parameters.
+	 * @param tokenValue the token value
+	 * @param issuedAt the time at which the token was issued
+	 * @param expiresAt the time at which the token expires
+	 */
+	public OAuth2DeviceCode(String tokenValue, Instant issuedAt, Instant expiresAt) {
+		super(tokenValue, issuedAt, expiresAt);
+	}
+
+}

+ 43 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2UserCode.java

@@ -0,0 +1,43 @@
+/*
+ * Copyright 2002-2023 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 java.time.Instant;
+
+/**
+ * An implementation of an {@link AbstractOAuth2Token} representing a user code as part of
+ * the OAuth 2.0 Device Authorization Grant.
+ *
+ * @author Steve Riesenberg
+ * @since 6.1
+ * @see OAuth2DeviceCode
+ * @see <a target="_blank" href= "https://tools.ietf.org/html/rfc8628#section-3.2">Section
+ * 3.2 Device Authorization Response</a>
+ */
+public final class OAuth2UserCode extends AbstractOAuth2Token {
+
+	/**
+	 * Constructs an {@code OAuth2UserCode} using the provided parameters.
+	 * @param tokenValue the token value
+	 * @param issuedAt the time at which the token was issued
+	 * @param expiresAt the time at which the token expires
+	 */
+	public OAuth2UserCode(String tokenValue, Instant issuedAt, Instant expiresAt) {
+		super(tokenValue, issuedAt, expiresAt);
+	}
+
+}

+ 263 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java

@@ -0,0 +1,263 @@
+/*
+ * Copyright 2002-2023 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.endpoint;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Map;
+
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * A representation of an OAuth 2.0 Device Authorization Response.
+ *
+ * @author Steve Riesenberg
+ * @since 6.1
+ * @see OAuth2DeviceCode
+ * @see OAuth2UserCode
+ * @see <a target="_blank" href="https://tools.ietf.org/html/rfc8628#section-3.2">Section
+ * 3.2 Device Authorization Response</a>
+ */
+public final class OAuth2DeviceAuthorizationResponse {
+
+	private OAuth2DeviceCode deviceCode;
+
+	private OAuth2UserCode userCode;
+
+	private String verificationUri;
+
+	private String verificationUriComplete;
+
+	private long interval;
+
+	private Map<String, Object> additionalParameters;
+
+	private OAuth2DeviceAuthorizationResponse() {
+	}
+
+	/**
+	 * Returns the {@link OAuth2DeviceCode Device Code}.
+	 * @return the {@link OAuth2DeviceCode}
+	 */
+	public OAuth2DeviceCode getDeviceCode() {
+		return this.deviceCode;
+	}
+
+	/**
+	 * Returns the {@link OAuth2UserCode User Code}.
+	 * @return the {@link OAuth2UserCode}
+	 */
+	public OAuth2UserCode getUserCode() {
+		return this.userCode;
+	}
+
+	/**
+	 * Returns the end-user verification URI.
+	 * @return the end-user verification URI
+	 */
+	public String getVerificationUri() {
+		return this.verificationUri;
+	}
+
+	/**
+	 * Returns the end-user verification URI that includes the user code.
+	 * @return the end-user verification URI that includes the user code
+	 */
+	public String getVerificationUriComplete() {
+		return this.verificationUriComplete;
+	}
+
+	/**
+	 * Returns the minimum amount of time (in seconds) that the client should wait between
+	 * polling requests to the token endpoint.
+	 * @return the minimum amount of time between polling requests
+	 */
+	public long getInterval() {
+		return this.interval;
+	}
+
+	/**
+	 * Returns the additional parameters returned in the response.
+	 * @return a {@code Map} of the additional parameters returned in the response, may be
+	 * empty.
+	 */
+	public Map<String, Object> getAdditionalParameters() {
+		return this.additionalParameters;
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided device code and user
+	 * code values.
+	 * @param deviceCode the value of the device code
+	 * @param userCode the value of the user code
+	 * @return the {@link Builder}
+	 */
+	public static Builder with(String deviceCode, String userCode) {
+		Assert.hasText(deviceCode, "deviceCode cannot be empty");
+		Assert.hasText(userCode, "userCode cannot be empty");
+		return new Builder(deviceCode, userCode);
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided device code and user
+	 * code.
+	 * @param deviceCode the {@link OAuth2DeviceCode}
+	 * @param userCode the {@link OAuth2UserCode}
+	 * @return the {@link Builder}
+	 */
+	public static Builder with(OAuth2DeviceCode deviceCode, OAuth2UserCode userCode) {
+		Assert.notNull(deviceCode, "deviceCode cannot be null");
+		Assert.notNull(userCode, "userCode cannot be null");
+		return new Builder(deviceCode, userCode);
+	}
+
+	/**
+	 * Returns a new {@link Builder}, initialized with the provided response.
+	 * @param deviceAuthorizationResponse the response to initialize the builder with
+	 * @return the {@link Builder}
+	 */
+	public static Builder withResponse(OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse) {
+		Assert.notNull(deviceAuthorizationResponse, "deviceAuthorizationResponse cannot be null");
+		return new Builder(deviceAuthorizationResponse);
+	}
+
+	/**
+	 * A builder for {@link OAuth2DeviceAuthorizationResponse}.
+	 */
+	public static final class Builder {
+
+		private final String deviceCode;
+
+		private final String userCode;
+
+		private String verificationUri;
+
+		private String verificationUriComplete;
+
+		private long expiresIn;
+
+		private long interval;
+
+		private Map<String, Object> additionalParameters;
+
+		private Builder(OAuth2DeviceAuthorizationResponse response) {
+			OAuth2DeviceCode deviceCode = response.getDeviceCode();
+			OAuth2UserCode userCode = response.getUserCode();
+			this.deviceCode = deviceCode.getTokenValue();
+			this.userCode = userCode.getTokenValue();
+			this.verificationUri = response.getVerificationUri();
+			this.verificationUriComplete = response.getVerificationUriComplete();
+			this.expiresIn = ChronoUnit.SECONDS.between(deviceCode.getIssuedAt(), deviceCode.getExpiresAt());
+			this.interval = response.getInterval();
+		}
+
+		private Builder(OAuth2DeviceCode deviceCode, OAuth2UserCode userCode) {
+			this.deviceCode = deviceCode.getTokenValue();
+			this.userCode = userCode.getTokenValue();
+			this.expiresIn = ChronoUnit.SECONDS.between(deviceCode.getIssuedAt(), deviceCode.getExpiresAt());
+		}
+
+		private Builder(String deviceCode, String userCode) {
+			this.deviceCode = deviceCode;
+			this.userCode = userCode;
+		}
+
+		/**
+		 * Sets the end-user verification URI.
+		 * @param verificationUri the end-user verification URI
+		 * @return the {@link Builder}
+		 */
+		public Builder verificationUri(String verificationUri) {
+			this.verificationUri = verificationUri;
+			return this;
+		}
+
+		/**
+		 * Sets the end-user verification URI that includes the user code.
+		 * @param verificationUriComplete the end-user verification URI that includes the
+		 * user code
+		 * @return the {@link Builder}
+		 */
+		public Builder verificationUriComplete(String verificationUriComplete) {
+			this.verificationUriComplete = verificationUriComplete;
+			return this;
+		}
+
+		/**
+		 * Sets the lifetime (in seconds) of the device code and user code.
+		 * @param expiresIn the lifetime (in seconds) of the device code and user code
+		 * @return the {@link Builder}
+		 */
+		public Builder expiresIn(long expiresIn) {
+			this.expiresIn = expiresIn;
+			return this;
+		}
+
+		/**
+		 * Sets the minimum amount of time (in seconds) that the client should wait
+		 * between polling requests to the token endpoint.
+		 * @param interval the minimum amount of time between polling requests
+		 * @return the {@link Builder}
+		 */
+		public Builder interval(long interval) {
+			this.interval = interval;
+			return this;
+		}
+
+		/**
+		 * Sets the additional parameters returned in the response.
+		 * @param additionalParameters the additional parameters returned in the response
+		 * @return the {@link Builder}
+		 */
+		public Builder additionalParameters(Map<String, Object> additionalParameters) {
+			this.additionalParameters = additionalParameters;
+			return this;
+		}
+
+		/**
+		 * Builds a new {@link OAuth2DeviceAuthorizationResponse}.
+		 * @return a {@link OAuth2DeviceAuthorizationResponse}
+		 */
+		public OAuth2DeviceAuthorizationResponse build() {
+			Assert.hasText(this.verificationUri, "verificationUri cannot be empty");
+			Assert.isTrue(this.expiresIn > 0, "expiresIn must be greater than zero");
+
+			Instant issuedAt = Instant.now();
+			Instant expiresAt = issuedAt.plusSeconds(this.expiresIn);
+			OAuth2DeviceCode deviceCode = new OAuth2DeviceCode(this.deviceCode, issuedAt, expiresAt);
+			OAuth2UserCode userCode = new OAuth2UserCode(this.userCode, issuedAt, expiresAt);
+
+			OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = new OAuth2DeviceAuthorizationResponse();
+			deviceAuthorizationResponse.deviceCode = deviceCode;
+			deviceAuthorizationResponse.userCode = userCode;
+			deviceAuthorizationResponse.verificationUri = this.verificationUri;
+			deviceAuthorizationResponse.verificationUriComplete = this.verificationUriComplete;
+			deviceAuthorizationResponse.interval = this.interval;
+			deviceAuthorizationResponse.additionalParameters = Collections
+					.unmodifiableMap(CollectionUtils.isEmpty(this.additionalParameters) ? Collections.emptyMap()
+							: this.additionalParameters);
+
+			return deviceAuthorizationResponse;
+		}
+
+	}
+
+}

+ 34 - 1
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2022 the original author or authors.
+ * Copyright 2002-2023 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.
@@ -22,6 +22,7 @@ package org.springframework.security.oauth2.core.endpoint;
  * endpoint.
  *
  * @author Joe Grandja
+ * @author Steve Riesenberg
  * @since 5.0
  * @see <a target="_blank" href="https://tools.ietf.org/html/rfc6749#section-11.2">11.2
  * OAuth Parameters Registry</a>
@@ -150,6 +151,38 @@ public final class OAuth2ParameterNames {
 	 */
 	public static final String TOKEN_TYPE_HINT = "token_type_hint";
 
+	/**
+	 * {@code device_code} - used in Device Authorization Request and Device Authorization
+	 * Response.
+	 * @since 6.1
+	 */
+	public static final String DEVICE_CODE = "device_code";
+
+	/**
+	 * {@code user_code} - used in Device Authorization Request and Device Authorization
+	 * Response.
+	 * @since 6.1
+	 */
+	public static final String USER_CODE = "user_code";
+
+	/**
+	 * {@code verification_uri} - Used in Device Authorization Response.
+	 * @since 6.1
+	 */
+	public static final String VERIFICATION_URI = "verification_uri";
+
+	/**
+	 * {@code verification_uri_complete} - Used in Device Authorization Response.
+	 * @since 6.1
+	 */
+	public static final String VERIFICATION_URI_COMPLETE = "verification_uri_complete";
+
+	/**
+	 * {@code interval} - Used in Device Authorization Response.
+	 * @since 6.1
+	 */
+	public static final String INTERVAL = "interval";
+
 	private OAuth2ParameterNames() {
 	}
 

+ 232 - 0
oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java

@@ -0,0 +1,232 @@
+/*
+ * Copyright 2002-2023 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> jsonMessageConvereter = 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.jsonMessageConvereter
+					.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.jsonMessageConvereter.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 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 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> 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;
+		}
+
+	}
+
+	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) {
+				return ChronoUnit.SECONDS.between(Instant.now(),
+						deviceAuthorizationResponse.getDeviceCode().getExpiresAt());
+			}
+			return -1;
+		}
+
+	}
+
+}

+ 206 - 0
oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverterTest.java

@@ -0,0 +1,206 @@
+/*
+ * Copyright 2002-2023 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.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.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.endpoint.OAuth2DeviceAuthorizationResponse;
+
+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.entry;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationResponseHttpMessageConverter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationResponseHttpMessageConverterTest {
+
+	private OAuth2DeviceAuthorizationResponseHttpMessageConverter messageConverter;
+
+	@BeforeEach
+	public void setup() {
+		this.messageConverter = new OAuth2DeviceAuthorizationResponseHttpMessageConverter();
+	}
+
+	@Test
+	public void supportsWhenOAuth2DeviceAuthorizationResponseThenTrue() {
+		assertThat(this.messageConverter.supports(OAuth2DeviceAuthorizationResponse.class)).isTrue();
+	}
+
+	@Test
+	public void setDeviceAuthorizationResponseConverterWhenConverterIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.messageConverter.setDeviceAuthorizationResponseConverter(null));
+	}
+
+	@Test
+	public void setDeviceAuthorizationResponseParametersConverterWhenConverterIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.messageConverter.setDeviceAuthorizationResponseParametersConverter(null));
+	}
+
+	@Test
+	public void readInternalWhenSuccessfulResponseWithAllParametersThenReadOAuth2DeviceAuthorizationResponse() {
+		// @formatter:off
+		String authorizationResponse = """
+				{
+					"device_code": "GmRhm_DnyEy",
+					"user_code": "WDJB-MJHT",
+					"verification_uri": "https://example.com/device",
+					"verification_uri_complete": "https://example.com/device?user_code=WDJB-MJHT",
+					"expires_in": 1800,
+					"interval": 5,
+					"custom_parameter_1": "custom-value-1",
+					"custom_parameter_2": "custom-value-2"
+				}
+				""";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(authorizationResponse.getBytes(), HttpStatus.OK);
+		OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = this.messageConverter
+				.readInternal(OAuth2DeviceAuthorizationResponse.class, response);
+		assertThat(deviceAuthorizationResponse.getDeviceCode().getTokenValue())
+				.isEqualTo("GmRhm_DnyEy");
+		assertThat(deviceAuthorizationResponse.getDeviceCode().getIssuedAt()).isNotNull();
+		assertThat(deviceAuthorizationResponse.getDeviceCode().getExpiresAt())
+				.isBeforeOrEqualTo(Instant.now().plusSeconds(1800));
+		assertThat(deviceAuthorizationResponse.getUserCode().getTokenValue()).isEqualTo("WDJB-MJHT");
+		assertThat(deviceAuthorizationResponse.getUserCode().getIssuedAt())
+				.isEqualTo(deviceAuthorizationResponse.getDeviceCode().getIssuedAt());
+		assertThat(deviceAuthorizationResponse.getUserCode().getExpiresAt())
+				.isEqualTo(deviceAuthorizationResponse.getDeviceCode().getExpiresAt());
+		assertThat(deviceAuthorizationResponse.getVerificationUri()).isEqualTo("https://example.com/device");
+		assertThat(deviceAuthorizationResponse.getVerificationUriComplete())
+				.isEqualTo("https://example.com/device?user_code=WDJB-MJHT");
+		assertThat(deviceAuthorizationResponse.getInterval()).isEqualTo(5);
+		assertThat(deviceAuthorizationResponse.getAdditionalParameters()).containsExactly(
+				entry("custom_parameter_1", "custom-value-1"), entry("custom_parameter_2", "custom-value-2"));
+	}
+
+	@Test
+	public void readInternalWhenSuccessfulResponseWithNullValuesThenReadOAuth2DeviceAuthorizationResponse() {
+		// @formatter:off
+		String authorizationResponse = """
+				{
+					"device_code": "GmRhm_DnyEy",
+					"user_code": "WDJB-MJHT",
+					"verification_uri": "https://example.com/device",
+					"verification_uri_complete": null,
+					"expires_in": 1800,
+					"interval": null
+				}
+				""";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(authorizationResponse.getBytes(), HttpStatus.OK);
+		OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = this.messageConverter
+				.readInternal(OAuth2DeviceAuthorizationResponse.class, response);
+		assertThat(deviceAuthorizationResponse.getDeviceCode().getTokenValue())
+				.isEqualTo("GmRhm_DnyEy");
+		assertThat(deviceAuthorizationResponse.getDeviceCode().getIssuedAt()).isNotNull();
+		assertThat(deviceAuthorizationResponse.getDeviceCode().getExpiresAt())
+				.isBeforeOrEqualTo(Instant.now().plusSeconds(1800));
+		assertThat(deviceAuthorizationResponse.getUserCode().getTokenValue()).isEqualTo("WDJB-MJHT");
+		assertThat(deviceAuthorizationResponse.getUserCode().getIssuedAt())
+				.isEqualTo(deviceAuthorizationResponse.getDeviceCode().getIssuedAt());
+		assertThat(deviceAuthorizationResponse.getUserCode().getExpiresAt())
+				.isEqualTo(deviceAuthorizationResponse.getDeviceCode().getExpiresAt());
+		assertThat(deviceAuthorizationResponse.getVerificationUri()).isEqualTo("https://example.com/device");
+		assertThat(deviceAuthorizationResponse.getVerificationUriComplete()).isNull();
+		assertThat(deviceAuthorizationResponse.getInterval()).isEqualTo(0);
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void readInternalWhenConversionFailsThenThrowHttpMessageNotReadableException() {
+		Converter<Map<String, Object>, OAuth2DeviceAuthorizationResponse> deviceAuthorizationResponseConverter = mock(
+				Converter.class);
+		given(deviceAuthorizationResponseConverter.convert(any())).willThrow(RuntimeException.class);
+		this.messageConverter.setDeviceAuthorizationResponseConverter(deviceAuthorizationResponseConverter);
+		String authorizationResponse = "{}";
+		MockClientHttpResponse response = new MockClientHttpResponse(authorizationResponse.getBytes(), HttpStatus.OK);
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+				.isThrownBy(() -> this.messageConverter.readInternal(OAuth2DeviceAuthorizationResponse.class, response))
+				.withMessageContaining("An error occurred reading the OAuth 2.0 Device Authorization Response");
+	}
+
+	@Test
+	public void writeInternalWhenOAuth2DeviceAuthorizationResponseThenWriteResponse() {
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("custom_parameter_1", "custom-value-1");
+		additionalParameters.put("custom_parameter_2", "custom-value-2");
+		// @formatter:off
+		OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse =
+				OAuth2DeviceAuthorizationResponse.with("GmRhm_DnyEy", "WDJB-MJHT")
+						.verificationUri("https://example.com/device")
+						.verificationUriComplete("https://example.com/device?user_code=WDJB-MJHT")
+						.expiresIn(1800)
+						.interval(5)
+						.additionalParameters(additionalParameters)
+						.build();
+		// @formatter:on
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+		this.messageConverter.writeInternal(deviceAuthorizationResponse, outputMessage);
+		String authorizationResponse = outputMessage.getBodyAsString();
+		assertThat(authorizationResponse).contains("\"device_code\":\"GmRhm_DnyEy\"");
+		assertThat(authorizationResponse).contains("\"user_code\":\"WDJB-MJHT\"");
+		assertThat(authorizationResponse).contains("\"verification_uri\":\"https://example.com/device\"");
+		assertThat(authorizationResponse)
+				.contains("\"verification_uri_complete\":\"https://example.com/device?user_code=WDJB-MJHT\"");
+		assertThat(authorizationResponse).contains("\"expires_in\":");
+		assertThat(authorizationResponse).contains("\"interval\":5");
+		assertThat(authorizationResponse).contains("\"custom_parameter_1\":\"custom-value-1\"");
+		assertThat(authorizationResponse).contains("\"custom_parameter_2\":\"custom-value-2\"");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void writeInternalWhenConversionFailsThenThrowHttpMessageNotWritableException() {
+		Converter<OAuth2DeviceAuthorizationResponse, Map<String, Object>> deviceAuthorizationResponseParametersConverter = mock(
+				Converter.class);
+		given(deviceAuthorizationResponseParametersConverter.convert(any())).willThrow(RuntimeException.class);
+		this.messageConverter
+				.setDeviceAuthorizationResponseParametersConverter(deviceAuthorizationResponseParametersConverter);
+		// @formatter:off
+		OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse =
+				OAuth2DeviceAuthorizationResponse.with("GmRhm_DnyEy", "WDJB-MJHT")
+						.verificationUri("https://example.com/device")
+						.expiresIn(1800)
+						.build();
+		// @formatter:on
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+		assertThatExceptionOfType(HttpMessageNotWritableException.class)
+				.isThrownBy(() -> this.messageConverter.writeInternal(deviceAuthorizationResponse, outputMessage))
+				.withMessageContaining("An error occurred writing the OAuth 2.0 Device Authorization Response");
+	}
+
+}