Add support for device authorization response

Closes gh-12852
This commit is contained in:
Steve Riesenberg 2023-03-08 13:30:17 -06:00
parent ac1d269e73
commit 8c17b978c8
No known key found for this signature in database
GPG Key ID: 5F311AB48A55D521
7 changed files with 829 additions and 2 deletions

View File

@ -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;
/**

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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() {
}

View File

@ -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;
}
}
}

View File

@ -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");
}
}