From 73c4b1687ed9baf53e1f4e48e5555bbea837a6f9 Mon Sep 17 00:00:00 2001 From: Steve Riesenberg Date: Wed, 8 Mar 2023 13:30:17 -0600 Subject: [PATCH] Add support for device authorization response Closes gh-12852 --- .../oauth2/core/AuthorizationGrantType.java | 8 +- .../oauth2/core/OAuth2DeviceCode.java | 43 +++ .../security/oauth2/core/OAuth2UserCode.java | 43 +++ .../OAuth2DeviceAuthorizationResponse.java | 263 ++++++++++++++++++ .../core/endpoint/OAuth2ParameterNames.java | 35 ++- ...orizationResponseHttpMessageConverter.java | 232 +++++++++++++++ ...ationResponseHttpMessageConverterTest.java | 206 ++++++++++++++ 7 files changed, 828 insertions(+), 2 deletions(-) create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2DeviceCode.java create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2UserCode.java create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java create mode 100644 oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java create mode 100644 oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverterTest.java diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java index 29501ae5141..49e75113568 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/AuthorizationGrantType.java +++ b/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. @@ -62,6 +62,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; /** diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2DeviceCode.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2DeviceCode.java new file mode 100644 index 00000000000..95ffa0f847a --- /dev/null +++ b/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 Section + * 3.2 Device Authorization Response + */ +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); + } + +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2UserCode.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/OAuth2UserCode.java new file mode 100644 index 00000000000..b0ee1776221 --- /dev/null +++ b/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 Section + * 3.2 Device Authorization Response + */ +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); + } + +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2DeviceAuthorizationResponse.java new file mode 100644 index 00000000000..c4cedd59afe --- /dev/null +++ b/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 Section + * 3.2 Device Authorization Response + */ +public final class OAuth2DeviceAuthorizationResponse { + + private OAuth2DeviceCode deviceCode; + + private OAuth2UserCode userCode; + + private String verificationUri; + + private String verificationUriComplete; + + private long interval; + + private Map 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 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 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 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; + } + + } + +} diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java index ac1bb11e65a..9d0c653d5ef 100644 --- a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/endpoint/OAuth2ParameterNames.java +++ b/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 @@ * endpoint. * * @author Joe Grandja + * @author Steve Riesenberg * @since 5.0 * @see 11.2 * OAuth Parameters Registry @@ -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() { } diff --git a/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java b/oauth2/oauth2-core/src/main/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverter.java new file mode 100644 index 00000000000..c1e67805a81 --- /dev/null +++ b/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 { + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() { + }; + + private final GenericHttpMessageConverter jsonMessageConvereter = HttpMessageConverters + .getJsonMessageConverter(); + + private Converter, OAuth2DeviceAuthorizationResponse> deviceAuthorizationResponseConverter = new DefaultMapOAuth2DeviceAuthorizationResponseConverter(); + + private Converter> deviceAuthorizationResponseParametersConverter = new DefaultOAuth2DeviceAuthorizationResponseMapConverter(); + + @Override + protected boolean supports(Class clazz) { + return OAuth2DeviceAuthorizationResponse.class.isAssignableFrom(clazz); + } + + @Override + @SuppressWarnings("unchecked") + protected OAuth2DeviceAuthorizationResponse readInternal(Class clazz, + HttpInputMessage inputMessage) throws HttpMessageNotReadableException { + + try { + Map deviceAuthorizationResponseParameters = (Map) 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 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, 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> deviceAuthorizationResponseParametersConverter) { + Assert.notNull(deviceAuthorizationResponseParametersConverter, + "deviceAuthorizationResponseParametersConverter cannot be null"); + this.deviceAuthorizationResponseParametersConverter = deviceAuthorizationResponseParametersConverter; + } + + private static final class DefaultMapOAuth2DeviceAuthorizationResponseConverter + implements Converter, OAuth2DeviceAuthorizationResponse> { + + private static final Set 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 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 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 parameters, String parameterName) { + Object obj = parameters.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; + } + + } + + private static final class DefaultOAuth2DeviceAuthorizationResponseMapConverter + implements Converter> { + + @Override + public Map convert(OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse) { + Map 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; + } + + } + +} diff --git a/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverterTest.java b/oauth2/oauth2-core/src/test/java/org/springframework/security/oauth2/core/http/converter/OAuth2DeviceAuthorizationResponseHttpMessageConverterTest.java new file mode 100644 index 00000000000..7742f6dc63f --- /dev/null +++ b/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, 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 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> 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"); + } + +}