11using System ;
22using System . Threading . Tasks ;
33using System . Collections . Generic ;
4+ using System . Globalization ;
45using System . Net . Http ;
56using System . Text ;
67using System . Threading ;
@@ -16,7 +17,9 @@ public interface IGitHubAuthentication : IDisposable
1617
1718 Task < string > GetTwoFactorCodeAsync ( Uri targetUri , bool isSms ) ;
1819
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 ) ;
2023 }
2124
2225 public class AuthenticationPromptResult
@@ -43,9 +46,11 @@ public enum AuthenticationModes
4346 None = 0 ,
4447 Basic = 1 ,
4548 Browser = 1 << 1 ,
46- Pat = 1 << 2 ,
49+ Pat = 1 << 2 ,
50+ Device = 1 << 3 ,
4751
48- All = Basic | Browser | Pat
52+ OAuth = Browser | Device ,
53+ All = Basic | OAuth | Pat
4954 }
5055
5156 public class GitHubAuthentication : AuthenticationBase , IGitHubAuthentication
@@ -62,6 +67,13 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
6267 {
6368 ThrowIfUserInteractionDisabled ( ) ;
6469
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!
6577 if ( modes == AuthenticationModes . None )
6678 {
6779 throw new ArgumentException ( @$ "Must specify at least one { nameof ( AuthenticationModes ) } ", nameof ( modes ) ) ;
@@ -78,6 +90,7 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
7890 {
7991 if ( ( modes & AuthenticationModes . Basic ) != 0 ) promptArgs . Append ( " --basic" ) ;
8092 if ( ( modes & AuthenticationModes . Browser ) != 0 ) promptArgs . Append ( " --browser" ) ;
93+ if ( ( modes & AuthenticationModes . Device ) != 0 ) promptArgs . Append ( " --device" ) ;
8194 if ( ( modes & AuthenticationModes . Pat ) != 0 ) promptArgs . Append ( " --pat" ) ;
8295 }
8396 if ( ! GitHubHostProvider . IsGitHubDotCom ( targetUri ) ) promptArgs . AppendFormat ( " --enterprise-url {0}" , QuoteCmdArg ( targetUri . ToString ( ) ) ) ;
@@ -104,6 +117,9 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
104117 case "browser" :
105118 return new AuthenticationPromptResult ( AuthenticationModes . Browser ) ;
106119
120+ case "device" :
121+ return new AuthenticationPromptResult ( AuthenticationModes . Device ) ;
122+
107123 case "basic" :
108124 if ( ! resultDict . TryGetValue ( "username" , out userName ) )
109125 {
@@ -148,6 +164,9 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
148164 case AuthenticationModes . Browser :
149165 return new AuthenticationPromptResult ( AuthenticationModes . Browser ) ;
150166
167+ case AuthenticationModes . Device :
168+ return new AuthenticationPromptResult ( AuthenticationModes . Device ) ;
169+
151170 case AuthenticationModes . Pat :
152171 Context . Terminal . WriteLine ( "Enter GitHub personal access token for '{0}'..." , targetUri ) ;
153172 string pat = Context . Terminal . PromptSecret ( "Token" ) ;
@@ -162,17 +181,20 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
162181 var menu = new TerminalMenu ( Context . Terminal , menuTitle ) ;
163182
164183 TerminalMenuItem browserItem = null ;
184+ TerminalMenuItem deviceItem = null ;
165185 TerminalMenuItem basicItem = null ;
166186 TerminalMenuItem patItem = null ;
167187
168188 if ( ( modes & AuthenticationModes . Browser ) != 0 ) browserItem = menu . Add ( "Web browser" ) ;
189+ if ( ( modes & AuthenticationModes . Device ) != 0 ) deviceItem = menu . Add ( "Device code" ) ;
169190 if ( ( modes & AuthenticationModes . Pat ) != 0 ) patItem = menu . Add ( "Personal access token" ) ;
170191 if ( ( modes & AuthenticationModes . Basic ) != 0 ) basicItem = menu . Add ( "Username/password" ) ;
171192
172193 // Default to the 'first' choice in the menu
173194 TerminalMenuItem choice = menu . Show ( 0 ) ;
174195
175196 if ( choice == browserItem ) goto case AuthenticationModes . Browser ;
197+ if ( choice == deviceItem ) goto case AuthenticationModes . Device ;
176198 if ( choice == basicItem ) goto case AuthenticationModes . Basic ;
177199 if ( choice == patItem ) goto case AuthenticationModes . Pat ;
178200
@@ -218,41 +240,90 @@ public async Task<string> GetTwoFactorCodeAsync(Uri targetUri, bool isSms)
218240 }
219241 }
220242
221- public async Task < OAuth2TokenResult > GetOAuthTokenAsync ( Uri targetUri , IEnumerable < string > scopes )
243+ public async Task < OAuth2TokenResult > GetOAuthTokenViaBrowserAsync ( Uri targetUri , IEnumerable < string > scopes )
222244 {
223245 ThrowIfUserInteractionDisabled ( ) ;
224246
225247 var oauthClient = new GitHubOAuth2Client ( HttpClient , Context . Settings , targetUri ) ;
226248
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
229256 {
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 )
231298 {
232- SuccessResponseHtml = GitHubResources . AuthenticationResponseSuccessHtml ,
233- FailureResponseHtmlFormat = GitHubResources . AuthenticationResponseFailureHtmlFormat
234- } ;
235- var browser = new OAuth2SystemWebBrowser ( Context . Environment , browserOptions ) ;
299+ tokenCts . Cancel ( ) ;
300+ }
236301
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+ }
239311
240- OAuth2AuthorizationCodeResult authCodeResult = await oauthClient . GetAuthorizationCodeAsync ( scopes , browser , CancellationToken . None ) ;
312+ // Close the dialog
313+ promptCts . Cancel ( ) ;
241314
242- return await oauthClient . GetTokenByAuthorizationCodeAsync ( authCodeResult , CancellationToken . None ) ;
315+ return tokenResult ;
243316 }
244317 else
245318 {
246319 ThrowIfTerminalPromptsDisabled ( ) ;
247320
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:" +
251322 Environment . NewLine +
252- deviceCodeResult . UserCode ;
323+ dcr . UserCode ;
253324 Context . Terminal . WriteLine ( deviceMessage ) ;
254325
255- return await oauthClient . GetTokenByDeviceCodeAsync ( deviceCodeResult , CancellationToken . None ) ;
326+ return await oauthClient . GetTokenByDeviceCodeAsync ( dcr , CancellationToken . None ) ;
256327 }
257328 }
258329
0 commit comments