1
1
using System ;
2
2
using System . Threading . Tasks ;
3
3
using System . Collections . Generic ;
4
+ using System . Globalization ;
4
5
using System . Net . Http ;
5
6
using System . Text ;
6
7
using System . Threading ;
@@ -16,7 +17,9 @@ public interface IGitHubAuthentication : IDisposable
16
17
17
18
Task < string > GetTwoFactorCodeAsync ( Uri targetUri , bool isSms ) ;
18
19
19
- Task < OAuth2TokenResult > GetOAuthTokenAsync ( Uri targetUri , IEnumerable < string > scopes ) ;
20
+ Task < OAuth2TokenResult > GetOAuthTokenViaBrowserAsync ( Uri targetUri , IEnumerable < string > scopes ) ;
21
+
22
+ Task < OAuth2TokenResult > GetOAuthTokenViaDeviceCodeAsync ( Uri targetUri , IEnumerable < string > scopes ) ;
20
23
}
21
24
22
25
public class AuthenticationPromptResult
@@ -43,9 +46,11 @@ public enum AuthenticationModes
43
46
None = 0 ,
44
47
Basic = 1 ,
45
48
Browser = 1 << 1 ,
46
- Pat = 1 << 2 ,
49
+ Pat = 1 << 2 ,
50
+ Device = 1 << 3 ,
47
51
48
- All = Basic | Browser | Pat
52
+ OAuth = Browser | Device ,
53
+ All = Basic | OAuth | Pat
49
54
}
50
55
51
56
public class GitHubAuthentication : AuthenticationBase , IGitHubAuthentication
@@ -62,6 +67,13 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
62
67
{
63
68
ThrowIfUserInteractionDisabled ( ) ;
64
69
70
+ // If we don't have a desktop session/GUI then we cannot offer browser
71
+ if ( ! Context . SessionManager . IsDesktopSession )
72
+ {
73
+ modes = modes & ~ AuthenticationModes . Browser ;
74
+ }
75
+
76
+ // We need at least one mode!
65
77
if ( modes == AuthenticationModes . None )
66
78
{
67
79
throw new ArgumentException ( @$ "Must specify at least one { nameof ( AuthenticationModes ) } ", nameof ( modes ) ) ;
@@ -78,6 +90,7 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
78
90
{
79
91
if ( ( modes & AuthenticationModes . Basic ) != 0 ) promptArgs . Append ( " --basic" ) ;
80
92
if ( ( modes & AuthenticationModes . Browser ) != 0 ) promptArgs . Append ( " --browser" ) ;
93
+ if ( ( modes & AuthenticationModes . Device ) != 0 ) promptArgs . Append ( " --device" ) ;
81
94
if ( ( modes & AuthenticationModes . Pat ) != 0 ) promptArgs . Append ( " --pat" ) ;
82
95
}
83
96
if ( ! GitHubHostProvider . IsGitHubDotCom ( targetUri ) ) promptArgs . AppendFormat ( " --enterprise-url {0}" , QuoteCmdArg ( targetUri . ToString ( ) ) ) ;
@@ -104,6 +117,9 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
104
117
case "browser" :
105
118
return new AuthenticationPromptResult ( AuthenticationModes . Browser ) ;
106
119
120
+ case "device" :
121
+ return new AuthenticationPromptResult ( AuthenticationModes . Device ) ;
122
+
107
123
case "basic" :
108
124
if ( ! resultDict . TryGetValue ( "username" , out userName ) )
109
125
{
@@ -148,6 +164,9 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
148
164
case AuthenticationModes . Browser :
149
165
return new AuthenticationPromptResult ( AuthenticationModes . Browser ) ;
150
166
167
+ case AuthenticationModes . Device :
168
+ return new AuthenticationPromptResult ( AuthenticationModes . Device ) ;
169
+
151
170
case AuthenticationModes . Pat :
152
171
Context . Terminal . WriteLine ( "Enter GitHub personal access token for '{0}'..." , targetUri ) ;
153
172
string pat = Context . Terminal . PromptSecret ( "Token" ) ;
@@ -162,17 +181,20 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
162
181
var menu = new TerminalMenu ( Context . Terminal , menuTitle ) ;
163
182
164
183
TerminalMenuItem browserItem = null ;
184
+ TerminalMenuItem deviceItem = null ;
165
185
TerminalMenuItem basicItem = null ;
166
186
TerminalMenuItem patItem = null ;
167
187
168
188
if ( ( modes & AuthenticationModes . Browser ) != 0 ) browserItem = menu . Add ( "Web browser" ) ;
189
+ if ( ( modes & AuthenticationModes . Device ) != 0 ) deviceItem = menu . Add ( "Device code" ) ;
169
190
if ( ( modes & AuthenticationModes . Pat ) != 0 ) patItem = menu . Add ( "Personal access token" ) ;
170
191
if ( ( modes & AuthenticationModes . Basic ) != 0 ) basicItem = menu . Add ( "Username/password" ) ;
171
192
172
193
// Default to the 'first' choice in the menu
173
194
TerminalMenuItem choice = menu . Show ( 0 ) ;
174
195
175
196
if ( choice == browserItem ) goto case AuthenticationModes . Browser ;
197
+ if ( choice == deviceItem ) goto case AuthenticationModes . Device ;
176
198
if ( choice == basicItem ) goto case AuthenticationModes . Basic ;
177
199
if ( choice == patItem ) goto case AuthenticationModes . Pat ;
178
200
@@ -218,41 +240,90 @@ public async Task<string> GetTwoFactorCodeAsync(Uri targetUri, bool isSms)
218
240
}
219
241
}
220
242
221
- public async Task < OAuth2TokenResult > GetOAuthTokenAsync ( Uri targetUri , IEnumerable < string > scopes )
243
+ public async Task < OAuth2TokenResult > GetOAuthTokenViaBrowserAsync ( Uri targetUri , IEnumerable < string > scopes )
222
244
{
223
245
ThrowIfUserInteractionDisabled ( ) ;
224
246
225
247
var oauthClient = new GitHubOAuth2Client ( HttpClient , Context . Settings , targetUri ) ;
226
248
227
- // If we have a desktop session try authentication using the user's default web browser
228
- if ( Context . SessionManager . IsDesktopSession )
249
+ // We require a desktop session to launch the user's default web browser
250
+ if ( ! Context . SessionManager . IsDesktopSession )
251
+ {
252
+ throw new InvalidOperationException ( "Browser authentication requires a desktop session" ) ;
253
+ }
254
+
255
+ var browserOptions = new OAuth2WebBrowserOptions
229
256
{
230
- var browserOptions = new OAuth2WebBrowserOptions
257
+ SuccessResponseHtml = GitHubResources . AuthenticationResponseSuccessHtml ,
258
+ FailureResponseHtmlFormat = GitHubResources . AuthenticationResponseFailureHtmlFormat
259
+ } ;
260
+ var browser = new OAuth2SystemWebBrowser ( Context . Environment , browserOptions ) ;
261
+
262
+ // Write message to the terminal (if any is attached) for some feedback that we're waiting for a web response
263
+ Context . Terminal . WriteLine ( "info: please complete authentication in your browser..." ) ;
264
+
265
+ OAuth2AuthorizationCodeResult authCodeResult =
266
+ await oauthClient . GetAuthorizationCodeAsync ( scopes , browser , CancellationToken . None ) ;
267
+
268
+ return await oauthClient . GetTokenByAuthorizationCodeAsync ( authCodeResult , CancellationToken . None ) ;
269
+ }
270
+
271
+ public async Task < OAuth2TokenResult > GetOAuthTokenViaDeviceCodeAsync ( Uri targetUri , IEnumerable < string > scopes )
272
+ {
273
+ ThrowIfUserInteractionDisabled ( ) ;
274
+
275
+ var oauthClient = new GitHubOAuth2Client ( HttpClient , Context . Settings , targetUri ) ;
276
+ OAuth2DeviceCodeResult dcr = await oauthClient . GetDeviceCodeAsync ( scopes , CancellationToken . None ) ;
277
+
278
+ // If we have a desktop session show the device code in a dialog
279
+ if ( Context . SessionManager . IsDesktopSession && TryFindHelperExecutablePath ( out string helperPath ) )
280
+ {
281
+ var args = new StringBuilder ( "device" ) ;
282
+ args . AppendFormat ( " --code {0} " , QuoteCmdArg ( dcr . UserCode ) ) ;
283
+ args . AppendFormat ( " --url {0}" , QuoteCmdArg ( dcr . VerificationUri . ToString ( ) ) ) ;
284
+
285
+ var promptCts = new CancellationTokenSource ( ) ;
286
+ var tokenCts = new CancellationTokenSource ( ) ;
287
+
288
+ // Show the dialog with the device code but don't await its closure
289
+ Task promptTask = InvokeHelperAsync ( helperPath , args . ToString ( ) , null , promptCts . Token ) ;
290
+
291
+ // Start the request for an OAuth token but don't wait
292
+ Task < OAuth2TokenResult > tokenTask = oauthClient . GetTokenByDeviceCodeAsync ( dcr , tokenCts . Token ) ;
293
+
294
+ Task t = await Task . WhenAny ( promptTask , tokenTask ) ;
295
+
296
+ // If the dialog was closed the user wishes to cancel the request
297
+ if ( t == promptTask )
231
298
{
232
- SuccessResponseHtml = GitHubResources . AuthenticationResponseSuccessHtml ,
233
- FailureResponseHtmlFormat = GitHubResources . AuthenticationResponseFailureHtmlFormat
234
- } ;
235
- var browser = new OAuth2SystemWebBrowser ( Context . Environment , browserOptions ) ;
299
+ tokenCts . Cancel ( ) ;
300
+ }
236
301
237
- // Write message to the terminal (if any is attached) for some feedback that we're waiting for a web response
238
- Context . Terminal . WriteLine ( "info: please complete authentication in your browser..." ) ;
302
+ OAuth2TokenResult tokenResult ;
303
+ try
304
+ {
305
+ tokenResult = await tokenTask ;
306
+ }
307
+ catch ( OperationCanceledException )
308
+ {
309
+ throw new Exception ( "User canceled device code authentication" ) ;
310
+ }
239
311
240
- OAuth2AuthorizationCodeResult authCodeResult = await oauthClient . GetAuthorizationCodeAsync ( scopes , browser , CancellationToken . None ) ;
312
+ // Close the dialog
313
+ promptCts . Cancel ( ) ;
241
314
242
- return await oauthClient . GetTokenByAuthorizationCodeAsync ( authCodeResult , CancellationToken . None ) ;
315
+ return tokenResult ;
243
316
}
244
317
else
245
318
{
246
319
ThrowIfTerminalPromptsDisabled ( ) ;
247
320
248
- OAuth2DeviceCodeResult deviceCodeResult = await oauthClient . GetDeviceCodeAsync ( scopes , CancellationToken . None ) ;
249
-
250
- string deviceMessage = $ "To complete authentication please visit { deviceCodeResult . VerificationUri } and enter the following code:" +
321
+ string deviceMessage = $ "To complete authentication please visit { dcr . VerificationUri } and enter the following code:" +
251
322
Environment . NewLine +
252
- deviceCodeResult . UserCode ;
323
+ dcr . UserCode ;
253
324
Context . Terminal . WriteLine ( deviceMessage ) ;
254
325
255
- return await oauthClient . GetTokenByDeviceCodeAsync ( deviceCodeResult , CancellationToken . None ) ;
326
+ return await oauthClient . GetTokenByDeviceCodeAsync ( dcr , CancellationToken . None ) ;
256
327
}
257
328
}
258
329
0 commit comments