Skip to content

Commit 7bb59c3

Browse files
author
Štěpán Schejbal
committed
initial support for device flow
1 parent a7b01f6 commit 7bb59c3

File tree

6 files changed

+172
-8
lines changed

6 files changed

+172
-8
lines changed

scribejava-apis/src/main/java/com/github/scribejava/apis/microsoftazureactivedirectory/BaseMicrosoftAzureActiveDirectoryApi.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ protected String getAuthorizationBaseUrl() {
3030
return MSFT_LOGIN_URL + tenant + OAUTH_2 + getEndpointVersionPath() + "/authorize";
3131
}
3232

33+
@Override
34+
public String getDeviceAuthorizationUrl() {
35+
return MSFT_LOGIN_URL + tenant + OAUTH_2 + getEndpointVersionPath() + "/devicecode";
36+
}
37+
3338
@Override
3439
public ClientAuthentication getClientAuthentication() {
3540
return RequestBodyAuthenticationScheme.instance();

scribejava-core/src/main/java/com/github/scribejava/core/builder/api/DefaultApi20.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,8 @@ public BearerSignature getBearerSignature() {
121121
public ClientAuthentication getClientAuthentication() {
122122
return HttpBasicAuthenticationScheme.instance();
123123
}
124+
125+
public String getDeviceAuthorizationUrl() {
126+
return null;
127+
}
124128
}

scribejava-core/src/main/java/com/github/scribejava/core/extractors/OAuth2AccessTokenJsonExtractor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ protected OAuth2AccessToken createToken(String accessToken, String tokenType, In
9393
return new OAuth2AccessToken(accessToken, tokenType, expiresIn, refreshToken, scope, rawResponse);
9494
}
9595

96-
protected static JsonNode extractRequiredParameter(JsonNode errorNode, String parameterName, String rawResponse)
96+
public static JsonNode extractRequiredParameter(JsonNode errorNode, String parameterName, String rawResponse)
9797
throws OAuthException {
9898
final JsonNode value = errorNode.get(parameterName);
9999

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.github.scribejava.core.model;
2+
3+
public class DeviceCode {
4+
private String deviceCode;
5+
private String userCode;
6+
private String verificationUri;
7+
private int intervalSeconds;
8+
private long expiresAtMillis;
9+
10+
public DeviceCode(String deviceCode, String userCode, String verificationUri,
11+
int intervalSeconds, int expiresInSeconds) {
12+
this.deviceCode = deviceCode;
13+
this.userCode = userCode;
14+
this.verificationUri = verificationUri;
15+
this.intervalSeconds = intervalSeconds;
16+
expiresAtMillis = System.currentTimeMillis() + (expiresInSeconds * 1000);
17+
}
18+
19+
public String getDeviceCode() {
20+
return deviceCode;
21+
}
22+
23+
public String getUserCode() {
24+
return userCode;
25+
}
26+
27+
public String getVerificationUri() {
28+
return verificationUri;
29+
}
30+
31+
public int getIntervalSeconds() {
32+
return intervalSeconds;
33+
}
34+
35+
public long getExpiresAtMillis() {
36+
return expiresAtMillis;
37+
}
38+
}

scribejava-core/src/main/java/com/github/scribejava/core/oauth/OAuth20Service.java

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,38 @@
11
package com.github.scribejava.core.oauth;
22

3-
import java.io.IOException;
4-
import java.io.OutputStream;
5-
import java.util.concurrent.Future;
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
65
import com.github.scribejava.core.builder.api.DefaultApi20;
76
import com.github.scribejava.core.extractors.OAuth2AccessTokenJsonExtractor;
87
import com.github.scribejava.core.httpclient.HttpClient;
98
import com.github.scribejava.core.httpclient.HttpClientConfig;
9+
import com.github.scribejava.core.model.DeviceCode;
1010
import com.github.scribejava.core.model.OAuth2AccessToken;
11+
import com.github.scribejava.core.model.OAuth2AccessTokenErrorResponse;
1112
import com.github.scribejava.core.model.OAuth2Authorization;
1213
import com.github.scribejava.core.model.OAuthAsyncRequestCallback;
1314
import com.github.scribejava.core.model.OAuthConstants;
1415
import com.github.scribejava.core.model.OAuthRequest;
1516
import com.github.scribejava.core.model.Response;
1617
import com.github.scribejava.core.model.Verb;
18+
import com.github.scribejava.core.oauth2.OAuth2Error;
1719
import com.github.scribejava.core.pkce.PKCE;
18-
import java.util.Map;
19-
import java.util.concurrent.ExecutionException;
2020
import com.github.scribejava.core.revoke.TokenTypeHint;
21+
import java.io.IOException;
22+
import java.io.OutputStream;
2123
import java.io.UnsupportedEncodingException;
2224
import java.net.URLDecoder;
25+
import java.util.Map;
26+
import java.util.concurrent.ExecutionException;
27+
import java.util.concurrent.Future;
28+
29+
import static com.github.scribejava.core.extractors.OAuth2AccessTokenJsonExtractor.extractRequiredParameter;
30+
import static com.github.scribejava.core.oauth2.OAuth2Error.AUTHORIZATION_PENDING;
31+
import static com.github.scribejava.core.oauth2.OAuth2Error.SLOW_DOWN;
32+
import static java.lang.Thread.sleep;
2333

2434
public class OAuth20Service extends OAuthService {
35+
protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
2536

2637
private static final String VERSION = "2.0";
2738
private final DefaultApi20 api;
@@ -490,4 +501,83 @@ public String getResponseType() {
490501
public String getDefaultScope() {
491502
return defaultScope;
492503
}
504+
505+
/**
506+
* Requests a device code from a server.
507+
*
508+
* @see <a href="https://tools.ietf.org/html/rfc8628#section-3">rfc8628</a>
509+
* @see <a href="https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code">
510+
* azure v2-oauth2-device-code</a>
511+
*/
512+
public DeviceCode getDeviceCode() throws InterruptedException, ExecutionException, IOException {
513+
final OAuthRequest request = new OAuthRequest(Verb.POST, api.getDeviceAuthorizationUrl());
514+
request.addBodyParameter(OAuthConstants.CLIENT_ID, getApiKey());
515+
request.addBodyParameter(OAuthConstants.SCOPE, getDefaultScope());
516+
try (Response response = execute(request)) {
517+
final String body = response.getBody();
518+
if (response.getCode() == 200) {
519+
final JsonNode n = OBJECT_MAPPER.readTree(body);
520+
return new DeviceCode(
521+
extractRequiredParameter(n, "device_code", body).textValue(),
522+
extractRequiredParameter(n, "user_code", body).textValue(),
523+
extractRequiredParameter(n, "verification_uri", body).textValue(),
524+
n.path("interval").asInt(5),
525+
extractRequiredParameter(n, "expires_in", body).intValue());
526+
} else {
527+
OAuth2AccessTokenJsonExtractor.instance().generateError(body);
528+
throw new IllegalStateException(); // generateError() always throws an exception
529+
}
530+
}
531+
}
532+
533+
/**
534+
* Attempts to get a token from a server.
535+
* Function {@link #pollDeviceAccessToken(DeviceCode)} is usually used instead of this.
536+
*
537+
* @return token
538+
* @throws OAuth2AccessTokenErrorResponse
539+
* If {@link OAuth2AccessTokenErrorResponse#getError()} is
540+
* {@link OAuth2Error#AUTHORIZATION_PENDING} or {@link OAuth2Error#SLOW_DOWN},
541+
* another attempt should be made after a while.
542+
*
543+
* @see #getDeviceCode()
544+
*/
545+
public OAuth2AccessToken getAccessTokenDeviceCodeGrant(DeviceCode deviceCode)
546+
throws IOException, InterruptedException, ExecutionException {
547+
final OAuthRequest request = new OAuthRequest(Verb.POST, api.getAccessTokenEndpoint());
548+
request.addParameter(OAuthConstants.GRANT_TYPE, "urn:ietf:params:oauth:grant-type:device_code");
549+
request.addBodyParameter(OAuthConstants.CLIENT_ID, getApiKey());
550+
request.addParameter("device_code", deviceCode.getDeviceCode());
551+
try (Response response = execute(request)) {
552+
return api.getAccessTokenExtractor().extract(response);
553+
}
554+
}
555+
556+
/**
557+
* Periodically tries to get a token from a server (waiting for the user to give consent).
558+
*
559+
* @return token
560+
* @throws OAuth2AccessTokenErrorResponse
561+
* Indicates OAuth error.
562+
*
563+
* @see #getDeviceCode()
564+
*/
565+
public OAuth2AccessToken pollDeviceAccessToken(DeviceCode deviceCode)
566+
throws InterruptedException, ExecutionException, IOException {
567+
long intervalMillis = deviceCode.getIntervalSeconds() * 1000;
568+
while (true) {
569+
try {
570+
return getAccessTokenDeviceCodeGrant(deviceCode);
571+
} catch (OAuth2AccessTokenErrorResponse e) {
572+
if (e.getError() != AUTHORIZATION_PENDING) {
573+
if (e.getError() == SLOW_DOWN) {
574+
intervalMillis += 5000;
575+
} else {
576+
throw e;
577+
}
578+
}
579+
}
580+
sleep(intervalMillis);
581+
}
582+
}
493583
}

scribejava-core/src/main/java/com/github/scribejava/core/oauth2/OAuth2Error.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package com.github.scribejava.core.oauth2;
22

3+
import java.util.EnumSet;
4+
import java.util.Set;
5+
6+
import static java.util.Collections.unmodifiableSet;
7+
38
public enum OAuth2Error {
49
/**
510
* @see <a href="https://tools.ietf.org/html/rfc6749#section-4.1.2.1">RFC 6749, 4.1.2.1 Error Response</a>
@@ -67,7 +72,29 @@ public enum OAuth2Error {
6772
* @see <a href="https://tools.ietf.org/html/rfc7009#section-4.1">RFC 7009, 4.1. OAuth Extensions Error
6873
* Registration</a>
6974
*/
70-
UNSUPPORTED_TOKEN_TYPE("unsupported_token_type");
75+
UNSUPPORTED_TOKEN_TYPE("unsupported_token_type"),
76+
77+
/**
78+
* @see <a href="https://tools.ietf.org/html/rfc8628#section-3.5">rfc8628#section-3.5</a>
79+
*/
80+
AUTHORIZATION_PENDING("authorization_pending"),
81+
82+
/**
83+
* @see <a href="https://tools.ietf.org/html/rfc8628#section-3.5">rfc8628#section-3.5</a>
84+
*/
85+
SLOW_DOWN("slow_down"),
86+
87+
/**
88+
* @see <a href="https://tools.ietf.org/html/rfc8628#section-3.5">rfc8628#section-3.5</a>
89+
*/
90+
EXPIRED_TOKEN("expired_token"),
91+
;
92+
93+
/**
94+
* Unlike {@link #values()} which creates a new array every time, this always
95+
* returns the same immutable object.
96+
*/
97+
public static final Set<OAuth2Error> VALUES = unmodifiableSet(EnumSet.allOf(OAuth2Error.class));
7198

7299
private final String errorString;
73100

@@ -76,7 +103,7 @@ public enum OAuth2Error {
76103
}
77104

78105
public static OAuth2Error parseFrom(String errorString) {
79-
for (OAuth2Error error : OAuth2Error.values()) {
106+
for (OAuth2Error error : VALUES) {
80107
if (error.errorString.equals(errorString)) {
81108
return error;
82109
}

0 commit comments

Comments
 (0)