-
Notifications
You must be signed in to change notification settings - Fork 775
Expand file tree
/
Copy pathDeviceFlowGithubAppAuthorizationProvider.java
More file actions
345 lines (303 loc) · 13.5 KB
/
DeviceFlowGithubAppAuthorizationProvider.java
File metadata and controls
345 lines (303 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
package org.kohsuke.github;
import org.kohsuke.github.authorization.AuthorizationProvider;
import org.kohsuke.github.authorization.DeviceFlowGithubAppCredentialListener;
import org.kohsuke.github.authorization.DeviceFlowGithubAppCredentials;
import org.kohsuke.github.authorization.DeviceFlowGithubAppInputManager;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.logging.Logger;
/**
* Provides authorization for GitHub applications using the device flow. See <a href=
* "https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app#using-the-device-flow-to-generate-a-user-access-token">...</a>
* This class handles the device flow process, including requesting device codes, polling for access tokens, refreshing
* tokens, and managing credential states.
*/
public class DeviceFlowGithubAppAuthorizationProvider extends GitHubInteractiveObject implements AuthorizationProvider {
/**
* Represents the response from GitHub's device flow access token endpoint. Contains access token, refresh token,
* expiration information, scope, and token type. We transform it to a {@link DeviceFlowGithubAppCredentials} object
* to expose it to the outside.
*/
private static class DeviceFlowAccessTokenResponse {
static DeviceFlowGithubAppCredentials toCredentials(DeviceFlowAccessTokenResponse response) {
var credentials = new DeviceFlowGithubAppCredentials();
credentials.setAccessToken(response.getAccessToken());
credentials.setExpiresIn(
response.getExpiresIn() > 0 ? Instant.now().plusSeconds(response.getExpiresIn()) : Instant.MIN);
credentials.setRefreshToken(response.getRefreshToken());
credentials.setRefreshTokenExpiresIn(response.getRefreshTokenExpiresIn() > 0
? Instant.now().plusSeconds(response.getRefreshTokenExpiresIn())
: Instant.MIN);
credentials.setScope(response.getScope());
credentials.setTokenType(response.getTokenType());
return credentials;
}
private String accessToken;
private int expiresIn;
private String refreshToken;
private int refreshTokenExpiresIn;
// should be empty
private String scope;
// should be Bearer
private String tokenType;
public String getAccessToken() {
return accessToken;
}
public int getExpiresIn() {
return expiresIn;
}
public String getRefreshToken() {
return refreshToken;
}
public int getRefreshTokenExpiresIn() {
return refreshTokenExpiresIn;
}
public String getScope() {
return scope;
}
public String getTokenType() {
return tokenType;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public void setExpiresIn(int expiresIn) {
this.expiresIn = expiresIn;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public void setRefreshTokenExpiresIn(int refreshTokenExpiresIn) {
this.refreshTokenExpiresIn = refreshTokenExpiresIn;
}
public void setScope(String scope) {
this.scope = scope;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
}
/**
* Represents the response from GitHub's device flow code endpoint. Contains device code, user code, verification
* URI, expiration, and polling interval.
*/
private static class DeviceFlowCodeResponse {
private String deviceCode;
private int expiresIn;
private int interval;
private String userCode;
private String verificationUri;
public String getDeviceCode() {
return deviceCode;
}
public int getExpiresIn() {
return expiresIn;
}
public int getInterval() {
return interval;
}
public String getUserCode() {
return userCode;
}
public String getVerificationUri() {
return verificationUri;
}
public void setDeviceCode(String deviceCode) {
this.deviceCode = deviceCode;
}
public void setExpiresIn(int expiresIn) {
this.expiresIn = expiresIn;
}
public void setInterval(int interval) {
this.interval = interval;
}
public void setUserCode(String userCode) {
this.userCode = userCode;
}
public void setVerificationUri(String verificationUri) {
this.verificationUri = verificationUri;
}
}
/**
* Represents the possible states of the credentials.
*/
private enum State {
EXPIRED_ACCESS_TOKEN, EXPIRED_REFRESH_TOKEN, NO_ACCESS_TOKEN, NO_REFRESH_TOKEN, VALID_ACCESS_TOKEN
}
private static final Logger LOGGER = Logger.getLogger(DeviceFlowGithubAppAuthorizationProvider.class.getName());
private static final int TOKEN_EXPIRATION_MARGIN_MINUTES = 5;
private static final int USER_VERIFICATION_CODE_ATTEMPTS = 20;
private final DeviceFlowGithubAppCredentialListener accessTokenListener;
private DeviceFlowGithubAppCredentials appCredentials;
private final String clientId;
private final DeviceFlowGithubAppInputManager inputManager;
/**
* Constructs a new DeviceFlowGithubAppAuthorizationProvider.
*
* @param clientId
* The client ID of the GitHub app.
* @param appCredentials
* The initial credentials for the app.
* @param accessTokenListener
* The listener to notify when new credentials are received (either the first time or through a refresh).
* @param inputManager
* The input manager for handling user input during the device flow (see
* {@link DeviceFlowGithubAppInputManager} for details).
* @throws IOException
* If an I/O error occurs.
*/
public DeviceFlowGithubAppAuthorizationProvider(String clientId,
DeviceFlowGithubAppCredentials appCredentials,
DeviceFlowGithubAppCredentialListener accessTokenListener,
DeviceFlowGithubAppInputManager inputManager) throws IOException {
this(clientId, appCredentials, accessTokenListener, inputManager, GitHub.connectAnonymously());
}
/**
* Constructs a new DeviceFlowGithubAppAuthorizationProvider with a specified GitHub instance. This is useful for
* testing, outside of tests you should not have to provide a GitHub instance.
*
* @param clientId
* The client ID of the GitHub app.
* @param appCredentials
* The initial credentials for the app.
* @param accessTokenListener
* The listener to notify when new credentials are received (either the first time or through a refresh).
* @param inputManager
* The input manager for handling user input during the device flow (see
* {@link DeviceFlowGithubAppInputManager} for details).
* @param github
* The GitHub instance to use for API requests.
*/
DeviceFlowGithubAppAuthorizationProvider(String clientId,
DeviceFlowGithubAppCredentials appCredentials,
DeviceFlowGithubAppCredentialListener accessTokenListener,
DeviceFlowGithubAppInputManager inputManager,
GitHub github) {
super(github);
this.clientId = clientId;
this.appCredentials = appCredentials;
this.accessTokenListener = accessTokenListener;
this.inputManager = inputManager;
}
/**
* {@inheritDoc}
*/
@Override
public String getEncodedAuthorization() throws IOException {
// 5 possible cases
// * very 1s call, we do not have anything, no access token, no refresh token
// * we have a valid access token
// * we have an expired access token
// * we do not have a refresh token
// * we have an expired refresh token
// Note that technically if the user did not properly persist the information we could have other states
// like for instance having an object with no access token but a refresh token, but let's KISS it for now
switch (getCredentialState()) {
case VALID_ACCESS_TOKEN :
return appCredentials.toEncodedCredentials();
case EXPIRED_ACCESS_TOKEN :
return refreshToken();
case NO_ACCESS_TOKEN :
case NO_REFRESH_TOKEN :
case EXPIRED_REFRESH_TOKEN :
default :
return performDeviceFlow();
}
}
private State getCredentialState() {
if (appCredentials == null || appCredentials.getAccessToken() == null) {
return State.NO_ACCESS_TOKEN;
}
if (appCredentials.getExpiresIn()
.minus(TOKEN_EXPIRATION_MARGIN_MINUTES, ChronoUnit.MINUTES)
.isAfter(Instant.now())) {
return State.VALID_ACCESS_TOKEN;
}
if (appCredentials.getRefreshToken() == null) {
return State.NO_REFRESH_TOKEN;
}
if (appCredentials.getRefreshTokenExpiresIn()
.minus(TOKEN_EXPIRATION_MARGIN_MINUTES, ChronoUnit.MINUTES)
.isAfter(Instant.now())) {
return State.EXPIRED_ACCESS_TOKEN;
}
return State.EXPIRED_REFRESH_TOKEN;
}
private String performDeviceFlow() throws IOException {
var deviceCodeResponse = requestDeviceCode();
inputManager.handleVerificationCodeFlow(deviceCodeResponse.getVerificationUri(),
deviceCodeResponse.getUserCode());
var accessTokenResponse = pollForAccessToken(deviceCodeResponse);
return refreshCredentialsAndNotifyListener(accessTokenResponse);
}
private DeviceFlowAccessTokenResponse pollForAccessToken(DeviceFlowCodeResponse deviceFlowCodeResponse)
throws IOException {
var attempts = 0;
while (attempts < USER_VERIFICATION_CODE_ATTEMPTS) {
var request = GitHubRequest.newBuilder()
.method("POST")
.setRawUrlPath("https://github.com/login/oauth/access_token")
.setHeader("Accept", "application/json")
.with("client_id", clientId)
.with("device_code", deviceFlowCodeResponse.getDeviceCode())
.with("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
.inBody()
.build();
var accessTokenResponse = root().getClient()
.sendRequest(request, r -> GitHubResponse.parseBody(r, DeviceFlowAccessTokenResponse.class))
.body();
if (accessTokenResponse != null && accessTokenResponse.getAccessToken() != null) {
LOGGER.finest("Access token obtained: " + accessTokenResponse.getAccessToken());
return accessTokenResponse;
}
var intervalSeconds = deviceFlowCodeResponse.getInterval();
if (intervalSeconds <= 0) {
// this is the default in the GitHub doc
intervalSeconds = 5;
}
attempts++;
LOGGER.finest(String.format("No access token, sleeping for %d seconds", intervalSeconds));
try {
Thread.sleep(intervalSeconds * 1000L);
} catch (InterruptedException e) {
throw (IOException) new InterruptedIOException().initCause(e);
}
}
throw new IOException("User failed to provide the verification code in the allocated time");
}
private String refreshCredentialsAndNotifyListener(DeviceFlowAccessTokenResponse accessTokenResponse)
throws IOException {
appCredentials = DeviceFlowAccessTokenResponse.toCredentials(accessTokenResponse);
accessTokenListener.onAccessTokenReceived(appCredentials);
return appCredentials.toEncodedCredentials();
}
private String refreshToken() throws IOException {
var request = GitHubRequest.newBuilder()
.method("POST")
.setRawUrlPath("https://github.com/login/oauth/access_token")
.setHeader("Accept", "application/json")
.with("client_id", clientId)
.with("grant_type", "refresh_token")
.with("refresh_token", appCredentials.getRefreshToken())
.inBody()
.build();
var accessTokenResponse = root().getClient()
.sendRequest(request, r -> GitHubResponse.parseBody(r, DeviceFlowAccessTokenResponse.class))
.body();
return refreshCredentialsAndNotifyListener(accessTokenResponse);
}
private DeviceFlowCodeResponse requestDeviceCode() throws IOException {
var request = GitHubRequest.newBuilder()
.method("POST")
.setRawUrlPath("https://github.com/login/device/code")
.setHeader("Accept", "application/json")
.with("client_id", clientId)
.inBody()
.build();
return root().getClient()
.sendRequest(request, r -> GitHubResponse.parseBody(r, DeviceFlowCodeResponse.class))
.body();
}
}