Skip to content

Commit c9c68bb

Browse files
Max BatischevMax Batischev
Max Batischev
authored and
Max Batischev
committed
Add DeviceCodeOAuth2AuthorizedClientProvider
Closes gh-11063
1 parent e771267 commit c9c68bb

9 files changed

+1134
-1
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.oauth2.client;
18+
19+
import java.time.Clock;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
23+
import org.springframework.security.oauth2.client.endpoint.DefaultDeviceCodeTokenResponseClient;
24+
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
25+
import org.springframework.security.oauth2.client.endpoint.OAuth2DeviceCodeGrantRequest;
26+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
27+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
28+
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
29+
import org.springframework.security.oauth2.core.OAuth2Token;
30+
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
31+
import org.springframework.util.Assert;
32+
33+
/**
34+
* An implementation of an {@link OAuth2AuthorizedClientProvider} for the
35+
* {@link AuthorizationGrantType#DEVICE_CODE device_code} grant.
36+
*
37+
* @author Max Batischev
38+
* @since 6.3
39+
* @see OAuth2AuthorizedClientProvider
40+
* @see DefaultDeviceCodeTokenResponseClient
41+
*/
42+
public final class DeviceCodeOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider {
43+
44+
private OAuth2AccessTokenResponseClient<OAuth2DeviceCodeGrantRequest> accessTokenResponseClient = new DefaultDeviceCodeTokenResponseClient();
45+
46+
private Duration clockSkew = Duration.ofSeconds(60);
47+
48+
private Clock clock = Clock.systemUTC();
49+
50+
/**
51+
* Attempt to authorize (or re-authorize) the
52+
* {@link OAuth2AuthorizationContext#getClientRegistration() client} in the provided
53+
* {@code context}. Returns {@code null} if authorization (or re-authorization) is not
54+
* supported, e.g. the client's {@link ClientRegistration#getAuthorizationGrantType()
55+
* authorization grant type} is not {@link AuthorizationGrantType#DEVICE_CODE
56+
* device_code} OR the {@link OAuth2AuthorizedClient#getAccessToken() access token} is
57+
* not expired.
58+
* @param context the context that holds authorization-specific state for the client
59+
* @return the {@link OAuth2AuthorizedClient} or {@code null} if authorization (or
60+
* re-authorization) is not supported
61+
*/
62+
@Override
63+
public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
64+
Assert.notNull(context, "context cannot be null");
65+
ClientRegistration clientRegistration = context.getClientRegistration();
66+
if (!AuthorizationGrantType.DEVICE_CODE.equals(clientRegistration.getAuthorizationGrantType())) {
67+
return null;
68+
}
69+
OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
70+
if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
71+
// If client is already authorized but access token is NOT expired than no
72+
// need for re-authorization
73+
return null;
74+
}
75+
String deviceCode = context.getAttribute(OAuth2AuthorizationContext.DEVICE_CODE_ATTRIBUTE_NAME);
76+
OAuth2DeviceCodeGrantRequest deviceCodeGrantRequest = new OAuth2DeviceCodeGrantRequest(clientRegistration,
77+
deviceCode);
78+
OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, deviceCodeGrantRequest);
79+
return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
80+
tokenResponse.getAccessToken());
81+
}
82+
83+
private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration,
84+
OAuth2DeviceCodeGrantRequest deviceCodeGrantRequest) {
85+
try {
86+
return this.accessTokenResponseClient.getTokenResponse(deviceCodeGrantRequest);
87+
}
88+
catch (OAuth2AuthorizationException ex) {
89+
throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex);
90+
}
91+
}
92+
93+
private boolean hasTokenExpired(OAuth2Token token) {
94+
return this.clock.instant().isAfter(token.getExpiresAt().minus(this.clockSkew));
95+
}
96+
97+
/**
98+
* Sets the client used when requesting an access token credential at the Token
99+
* Endpoint for the {@code client_credentials} grant.
100+
* @param accessTokenResponseClient the client used when requesting an access token
101+
* credential at the Token Endpoint for the {@code client_credentials} grant
102+
*/
103+
public void setAccessTokenResponseClient(
104+
OAuth2AccessTokenResponseClient<OAuth2DeviceCodeGrantRequest> accessTokenResponseClient) {
105+
Assert.notNull(accessTokenResponseClient, "accessTokenResponseClient cannot be null");
106+
this.accessTokenResponseClient = accessTokenResponseClient;
107+
}
108+
109+
/**
110+
* Sets the maximum acceptable clock skew, which is used when checking the
111+
* {@link OAuth2AuthorizedClient#getAccessToken() access token} expiry. The default is
112+
* 60 seconds.
113+
*
114+
* <p>
115+
* An access token is considered expired if
116+
* {@code OAuth2AccessToken#getExpiresAt() - clockSkew} is before the current time
117+
* {@code clock#instant()}.
118+
* @param clockSkew the maximum acceptable clock skew
119+
*/
120+
public void setClockSkew(Duration clockSkew) {
121+
Assert.notNull(clockSkew, "clockSkew cannot be null");
122+
Assert.isTrue(clockSkew.getSeconds() >= 0, "clockSkew must be >= 0");
123+
this.clockSkew = clockSkew;
124+
}
125+
126+
/**
127+
* Sets the {@link Clock} used in {@link Instant#now(Clock)} when checking the access
128+
* token expiry.
129+
* @param clock the clock
130+
*/
131+
public void setClock(Clock clock) {
132+
Assert.notNull(clock, "clock cannot be null");
133+
this.clock = clock;
134+
}
135+
136+
}

oauth2/oauth2-client/src/main/java/org/springframework/security/oauth2/client/OAuth2AuthorizationContext.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -60,6 +60,13 @@ public final class OAuth2AuthorizationContext {
6060
*/
6161
public static final String PASSWORD_ATTRIBUTE_NAME = OAuth2AuthorizationContext.class.getName().concat(".PASSWORD");
6262

63+
/**
64+
* The name of the {@link #getAttribute(String) attribute} in the context associated
65+
* to the value for the device code.
66+
*/
67+
public static final String DEVICE_CODE_ATTRIBUTE_NAME = OAuth2AuthorizationContext.class.getName()
68+
.concat(".DEVICE_CODE");
69+
6370
private ClientRegistration clientRegistration;
6471

6572
private OAuth2AuthorizedClient authorizedClient;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.oauth2.client.endpoint;
18+
19+
import java.util.Arrays;
20+
21+
import org.springframework.core.convert.converter.Converter;
22+
import org.springframework.http.RequestEntity;
23+
import org.springframework.http.ResponseEntity;
24+
import org.springframework.http.converter.FormHttpMessageConverter;
25+
import org.springframework.http.converter.HttpMessageConverter;
26+
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
27+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
28+
import org.springframework.security.oauth2.core.OAuth2AuthorizationException;
29+
import org.springframework.security.oauth2.core.OAuth2Error;
30+
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
31+
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
32+
import org.springframework.util.Assert;
33+
import org.springframework.web.client.ResponseErrorHandler;
34+
import org.springframework.web.client.RestClientException;
35+
import org.springframework.web.client.RestOperations;
36+
import org.springframework.web.client.RestTemplate;
37+
38+
/**
39+
* The default implementation of an {@link OAuth2AccessTokenResponseClient} for the
40+
* {@link AuthorizationGrantType#DEVICE_CODE device_code} grant. This implementation uses
41+
* a {@link RestOperations} when requesting an access token credential at the
42+
* Authorization Server's Token Endpoint.
43+
*
44+
* @author Max Batischev
45+
* @since 6.3
46+
* @see OAuth2AccessTokenResponseClient
47+
* @see OAuth2DeviceCodeGrantRequest
48+
* @see OAuth2AccessTokenResponse
49+
* @see <a target="_blank" href=
50+
* "https://datatracker.ietf.org/doc/html/rfc8628#section-3.4">Section 3.4 Device Access
51+
* Token Request</a>
52+
* @see <a target="_blank" href=
53+
* "https://datatracker.ietf.org/doc/html/rfc8628#section-3.5">Section 3.5 Device Access
54+
* Token Response</a>
55+
*/
56+
public final class DefaultDeviceCodeTokenResponseClient
57+
implements OAuth2AccessTokenResponseClient<OAuth2DeviceCodeGrantRequest> {
58+
59+
private static final String INVALID_TOKEN_RESPONSE_ERROR_CODE = "invalid_token_response";
60+
61+
private Converter<OAuth2DeviceCodeGrantRequest, RequestEntity<?>> requestEntityConverter = new OAuth2DeviceCodeGrantRequestEntityConverter();
62+
63+
private RestOperations restOperations;
64+
65+
public DefaultDeviceCodeTokenResponseClient() {
66+
RestTemplate restTemplate = new RestTemplate(
67+
Arrays.asList(new FormHttpMessageConverter(), new OAuth2AccessTokenResponseHttpMessageConverter()));
68+
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
69+
this.restOperations = restTemplate;
70+
}
71+
72+
@Override
73+
public OAuth2AccessTokenResponse getTokenResponse(OAuth2DeviceCodeGrantRequest deviceCodeGrantRequest) {
74+
Assert.notNull(deviceCodeGrantRequest, "deviceCodeGrantRequest cannot be null");
75+
RequestEntity<?> request = this.requestEntityConverter.convert(deviceCodeGrantRequest);
76+
ResponseEntity<OAuth2AccessTokenResponse> response = getResponse(request);
77+
return response.getBody();
78+
}
79+
80+
private ResponseEntity<OAuth2AccessTokenResponse> getResponse(RequestEntity<?> request) {
81+
try {
82+
return this.restOperations.exchange(request, OAuth2AccessTokenResponse.class);
83+
}
84+
catch (RestClientException ex) {
85+
OAuth2Error oauth2Error = new OAuth2Error(INVALID_TOKEN_RESPONSE_ERROR_CODE,
86+
"An error occurred while attempting to retrieve the OAuth 2.0 Access Token Response: "
87+
+ ex.getMessage(),
88+
null);
89+
throw new OAuth2AuthorizationException(oauth2Error, ex);
90+
}
91+
}
92+
93+
/**
94+
* Sets the {@link Converter} used for converting the
95+
* {@link OAuth2DeviceCodeGrantRequest} to a {@link RequestEntity} representation of
96+
* the OAuth 2.0 Access Token Request.
97+
* @param requestEntityConverter the {@link Converter} used for converting to a
98+
* {@link RequestEntity} representation of the Access Token Request
99+
*/
100+
public void setRequestEntityConverter(
101+
Converter<OAuth2DeviceCodeGrantRequest, RequestEntity<?>> requestEntityConverter) {
102+
Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null");
103+
this.requestEntityConverter = requestEntityConverter;
104+
}
105+
106+
/**
107+
* Sets the {@link RestOperations} used when requesting the OAuth 2.0 Access Token
108+
* Response.
109+
*
110+
* <p>
111+
* <b>NOTE:</b> At a minimum, the supplied {@code restOperations} must be configured
112+
* with the following:
113+
* <ol>
114+
* <li>{@link HttpMessageConverter}'s - {@link FormHttpMessageConverter} and
115+
* {@link OAuth2AccessTokenResponseHttpMessageConverter}</li>
116+
* <li>{@link ResponseErrorHandler} - {@link OAuth2ErrorResponseErrorHandler}</li>
117+
* </ol>
118+
* @param restOperations the {@link RestOperations} used when requesting the Access
119+
* Token Response
120+
*/
121+
public void setRestOperations(RestOperations restOperations) {
122+
Assert.notNull(restOperations, "restOperations cannot be null");
123+
this.restOperations = restOperations;
124+
}
125+
126+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.oauth2.client.endpoint;
18+
19+
import org.springframework.security.oauth2.client.registration.ClientRegistration;
20+
import org.springframework.security.oauth2.core.AuthorizationGrantType;
21+
22+
/**
23+
* An OAuth 2.0 Device Authorization Grant request that holds the Device Code in
24+
* {@link #getClientRegistration()}.
25+
*
26+
* @author Max Batischev
27+
* @since 6.3
28+
* @see <a target="_blank" href=
29+
* "https://datatracker.ietf.org/doc/html/rfc8628#section-3.4">Section 3.4 Device Access
30+
* Token Request</a>
31+
* @see AbstractOAuth2AuthorizationGrantRequest
32+
* @see ClientRegistration
33+
*/
34+
public class OAuth2DeviceCodeGrantRequest extends AbstractOAuth2AuthorizationGrantRequest {
35+
36+
private final String deviceCode;
37+
38+
/**
39+
* Constructs an {@code OAuth2AuthorizationCodeGrantRequest} using the provided
40+
* parameters.
41+
* @param clientRegistration the client registration
42+
* @param deviceCode the device code
43+
* @since 6.3
44+
*/
45+
public OAuth2DeviceCodeGrantRequest(ClientRegistration clientRegistration, String deviceCode) {
46+
super(AuthorizationGrantType.DEVICE_CODE, clientRegistration);
47+
this.deviceCode = deviceCode;
48+
}
49+
50+
public String getDeviceCode() {
51+
return this.deviceCode;
52+
}
53+
54+
}

0 commit comments

Comments
 (0)