Skip to content

Add DeviceCodeOAuth2AuthorizedClientProvider #14816

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright 2002-2024 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.client;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;

import org.springframework.security.oauth2.client.endpoint.DefaultDeviceCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2DeviceCodeGrantRequest;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Token;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.util.Assert;

/**
* An implementation of an {@link OAuth2AuthorizedClientProvider} for the
* {@link AuthorizationGrantType#DEVICE_CODE device_code} grant.
*
* @author Max Batischev
* @since 6.3
* @see OAuth2AuthorizedClientProvider
* @see DefaultDeviceCodeTokenResponseClient
*/
public final class DeviceCodeOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider {

private OAuth2AccessTokenResponseClient<OAuth2DeviceCodeGrantRequest> accessTokenResponseClient = new DefaultDeviceCodeTokenResponseClient();

private Duration clockSkew = Duration.ofSeconds(60);

private Clock clock = Clock.systemUTC();

/**
* Attempt to authorize (or re-authorize) the
* {@link OAuth2AuthorizationContext#getClientRegistration() client} in the provided
* {@code context}. Returns {@code null} if authorization (or re-authorization) is not
* supported, e.g. the client's {@link ClientRegistration#getAuthorizationGrantType()
* authorization grant type} is not {@link AuthorizationGrantType#DEVICE_CODE
* device_code} OR the {@link OAuth2AuthorizedClient#getAccessToken() access token} is
* not expired.
* @param context the context that holds authorization-specific state for the client
* @return the {@link OAuth2AuthorizedClient} or {@code null} if authorization (or
* re-authorization) is not supported
*/
@Override
public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
Assert.notNull(context, "context cannot be null");
ClientRegistration clientRegistration = context.getClientRegistration();
if (!AuthorizationGrantType.DEVICE_CODE.equals(clientRegistration.getAuthorizationGrantType())) {
return null;
}
OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
// If client is already authorized but access token is NOT expired than no
// need for re-authorization
return null;
}
String deviceCode = context.getAttribute(OAuth2AuthorizationContext.DEVICE_CODE_ATTRIBUTE_NAME);
OAuth2DeviceCodeGrantRequest deviceCodeGrantRequest = new OAuth2DeviceCodeGrantRequest(clientRegistration,
deviceCode);
OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, deviceCodeGrantRequest);
return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
tokenResponse.getAccessToken());
}

private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration,
OAuth2DeviceCodeGrantRequest deviceCodeGrantRequest) {
try {
return this.accessTokenResponseClient.getTokenResponse(deviceCodeGrantRequest);
}
catch (OAuth2AuthorizationException ex) {
throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex);
}
}

private boolean hasTokenExpired(OAuth2Token token) {
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
}

/**
* Sets the client used when requesting an access token credential at the Token
* Endpoint for the {@code client_credentials} grant.
* @param accessTokenResponseClient the client used when requesting an access token
* credential at the Token Endpoint for the {@code client_credentials} grant
*/
public void setAccessTokenResponseClient(
OAuth2AccessTokenResponseClient<OAuth2DeviceCodeGrantRequest> accessTokenResponseClient) {
Assert.notNull(accessTokenResponseClient, "accessTokenResponseClient cannot be null");
this.accessTokenResponseClient = accessTokenResponseClient;
}

/**
* Sets the maximum acceptable clock skew, which is used when checking the
* {@link OAuth2AuthorizedClient#getAccessToken() access token} expiry. The default is
* 60 seconds.
*
* <p>
* An access token is considered expired if
* {@code OAuth2AccessToken#getExpiresAt() - clockSkew} is before the current time
* {@code clock#instant()}.
* @param clockSkew the maximum acceptable clock skew
*/
public void setClockSkew(Duration clockSkew) {
Assert.notNull(clockSkew, "clockSkew cannot be null");
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
this.clockSkew = clockSkew;
}

/**
* Sets the {@link Clock} used in {@link Instant#now(Clock)} when checking the access
* token expiry.
* @param clock the clock
*/
public void setClock(Clock clock) {
Assert.notNull(clock, "clock cannot be null");
this.clock = clock;
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* Copyright 2002-2024 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.
Expand Down Expand Up @@ -60,6 +60,13 @@ public final class OAuth2AuthorizationContext {
*/
public static final String PASSWORD_ATTRIBUTE_NAME = OAuth2AuthorizationContext.class.getName().concat(".PASSWORD");

/**
* The name of the {@link #getAttribute(String) attribute} in the context associated
* to the value for the device code.
*/
public static final String DEVICE_CODE_ATTRIBUTE_NAME = OAuth2AuthorizationContext.class.getName()
.concat(".DEVICE_CODE");

private ClientRegistration clientRegistration;

private OAuth2AuthorizedClient authorizedClient;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* Copyright 2002-2024 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.client.endpoint;

import java.util.Arrays;

import org.springframework.core.convert.converter.Converter;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
import org.springframework.util.Assert;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestOperations;
import org.springframework.web.client.RestTemplate;

/**
* The default implementation of an {@link OAuth2AccessTokenResponseClient} for the
* {@link AuthorizationGrantType#DEVICE_CODE device_code} grant. This implementation uses
* a {@link RestOperations} when requesting an access token credential at the
* Authorization Server's Token Endpoint.
*
* @author Max Batischev
* @since 6.3
* @see OAuth2AccessTokenResponseClient
* @see OAuth2DeviceCodeGrantRequest
* @see OAuth2AccessTokenResponse
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc8628#section-3.4">Section 3.4 Device Access
* Token Request</a>
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc8628#section-3.5">Section 3.5 Device Access
* Token Response</a>
*/
public final class DefaultDeviceCodeTokenResponseClient
implements OAuth2AccessTokenResponseClient<OAuth2DeviceCodeGrantRequest> {

private static final String INVALID_TOKEN_RESPONSE_ERROR_CODE = "invalid_token_response";

private Converter<OAuth2DeviceCodeGrantRequest, RequestEntity<?>> requestEntityConverter = new OAuth2DeviceCodeGrantRequestEntityConverter();

private RestOperations restOperations;

public DefaultDeviceCodeTokenResponseClient() {
RestTemplate restTemplate = new RestTemplate(
Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
this.restOperations = restTemplate;
}

@Override
public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceCodeGrantRequest deviceCodeGrantRequest) {
Assert.notNull(deviceCodeGrantRequest, "deviceCodeGrantRequest cannot be null");
RequestEntity<?> request = this.requestEntityConverter.convert(deviceCodeGrantRequest);
ResponseEntity<OAuth2AccessTokenResponse> response = getResponse(request);
return response.getBody();
}

private ResponseEntity<OAuth2AccessTokenResponse> getResponse(RequestEntity<?> request) {
try {
return this.restOperations.exchange(request, OAuth2AccessTokenResponse.class);
}
catch (RestClientException ex) {
OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
+ ex.getMessage(),
null);
throw new OAuth2AuthorizationException(oauth2Error, ex);
}
}

/**
* Sets the {@link Converter} used for converting the
* {@link OAuth2DeviceCodeGrantRequest} to a {@link RequestEntity} representation of
* the OAuth 2.0 Access Token Request.
* @param requestEntityConverter the {@link Converter} used for converting to a
* {@link RequestEntity} representation of the Access Token Request
*/
public void setRequestEntityConverter(
Converter<OAuth2DeviceCodeGrantRequest, RequestEntity<?>> requestEntityConverter) {
Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null");
this.requestEntityConverter = requestEntityConverter;
}

/**
* Sets the {@link RestOperations} used when requesting the OAuth 2.0 Access Token
* Response.
*
* <p>
* <b>NOTE:</b> At a minimum, the supplied {@code restOperations} must be configured
* with the following:
* <ol>
* <li>{@link HttpMessageConverter}'s - {@link FormHttpMessageConverter} and
* {@link OAuth2AccessTokenResponseHttpMessageConverter}</li>
* <li>{@link ResponseErrorHandler} - {@link OAuth2ErrorResponseErrorHandler}</li>
* </ol>
* @param restOperations the {@link RestOperations} used when requesting the Access
* Token Response
*/
public void setRestOperations(RestOperations restOperations) {
Assert.notNull(restOperations, "restOperations cannot be null");
this.restOperations = restOperations;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2002-2024 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.client.endpoint;

import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;

/**
* An OAuth 2.0 Device Authorization Grant request that holds the Device Code in
* {@link #getClientRegistration()}.
*
* @author Max Batischev
* @since 6.3
* @see <a target="_blank" href=
* "https://datatracker.ietf.org/doc/html/rfc8628#section-3.4">Section 3.4 Device Access
* Token Request</a>
* @see AbstractOAuth2AuthorizationGrantRequest
* @see ClientRegistration
*/
public class OAuth2DeviceCodeGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {

private final String deviceCode;

/**
* Constructs an {@code OAuth2AuthorizationCodeGrantRequest} using the provided
* parameters.
* @param clientRegistration the client registration
* @param deviceCode the device code
* @since 6.3
*/
public OAuth2DeviceCodeGrantRequest(ClientRegistration clientRegistration, String deviceCode) {
super(AuthorizationGrantType.DEVICE_CODE, clientRegistration);
this.deviceCode = deviceCode;
}

public String getDeviceCode() {
return this.deviceCode;
}

}
Loading