Skip to content

Commit 28f487a

Browse files
committed
github: implement explicit device code flow option
Implement an explicit OAuth device code authentication mode for the GitHub host provider. Previously the 'web browser' option combined both the interactive/browser based flow (when a UI was present), and a TTY-based device code flow. This change allows users to select the device code flow even when they have a desktop/UI session present. This is useful to workaround possible problems with the browser loopback/redirect mechanism.
1 parent 0fdb03a commit 28f487a

File tree

4 files changed

+105
-29
lines changed

4 files changed

+105
-29
lines changed

src/shared/GitHub.Tests/GitHubHostProviderTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes(string uriS
121121
[Theory]
122122
[InlineData("https://example.com", null, "0.1", false, AuthenticationModes.Pat)]
123123
[InlineData("https://example.com", null, "0.1", true, AuthenticationModes.Basic | AuthenticationModes.Pat)]
124-
[InlineData("https://example.com", null, "100.0", false, AuthenticationModes.Browser | AuthenticationModes.Pat)]
124+
[InlineData("https://example.com", null, "100.0", false, AuthenticationModes.OAuth | AuthenticationModes.Pat)]
125125
[InlineData("https://example.com", null, "100.0", true, AuthenticationModes.All)]
126126
public async Task GitHubHostProvider_GetSupportedAuthenticationModes_WithMetadata(string uriString, string gitHubAuthModes,
127127
string installedVersion, bool verifiablePasswordAuthentication, AuthenticationModes expectedModes)
@@ -196,7 +196,7 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Browser_ReturnsCred
196196
ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny<AuthenticationModes>()))
197197
.ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.Browser));
198198

199-
ghAuthMock.Setup(x => x.GetOAuthTokenAsync(expectedTargetUri, It.IsAny<IEnumerable<string>>()))
199+
ghAuthMock.Setup(x => x.GetOAuthTokenViaBrowserAsync(expectedTargetUri, It.IsAny<IEnumerable<string>>()))
200200
.ReturnsAsync(response);
201201

202202
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
@@ -212,7 +212,7 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Browser_ReturnsCred
212212
Assert.Equal(tokenValue, credential.Password);
213213

214214
ghAuthMock.Verify(
215-
x => x.GetOAuthTokenAsync(
215+
x => x.GetOAuthTokenViaBrowserAsync(
216216
expectedTargetUri, expectedOAuthScopes),
217217
Times.Once);
218218
}

src/shared/GitHub/GitHubAuthentication.cs

Lines changed: 91 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Threading.Tasks;
33
using System.Collections.Generic;
4+
using System.Globalization;
45
using System.Net.Http;
56
using System.Text;
67
using 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

src/shared/GitHub/GitHubConstants.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public static class GitHubConstants
4141
/// As of 13th November 2020, GitHub.com does not support username/password (basic) authentication to the APIs.
4242
/// See https://developer.github.com/changes/2020-02-14-deprecating-oauth-auth-endpoint for more information.
4343
/// </remarks>
44-
public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.Browser | AuthenticationModes.Pat;
44+
public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.OAuth | AuthenticationModes.Pat;
4545

4646
public static class TokenScopes
4747
{

src/shared/GitHub/GitHubHostProvider.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,10 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
150150
return patCredential;
151151

152152
case AuthenticationModes.Browser:
153-
return await GenerateOAuthCredentialAsync(remoteUri);
153+
return await GenerateOAuthCredentialAsync(remoteUri, useBrowser: true);
154+
155+
case AuthenticationModes.Device:
156+
return await GenerateOAuthCredentialAsync(remoteUri, useBrowser: false);
154157

155158
case AuthenticationModes.Pat:
156159
// The token returned by the user should be good to use directly as the password for Git
@@ -173,9 +176,11 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
173176
}
174177
}
175178

176-
private async Task<GitCredential> GenerateOAuthCredentialAsync(Uri targetUri)
179+
private async Task<GitCredential> GenerateOAuthCredentialAsync(Uri targetUri, bool useBrowser)
177180
{
178-
OAuth2TokenResult result = await _gitHubAuth.GetOAuthTokenAsync(targetUri, GitHubOAuthScopes);
181+
OAuth2TokenResult result = useBrowser
182+
? await _gitHubAuth.GetOAuthTokenViaBrowserAsync(targetUri, GitHubOAuthScopes)
183+
: await _gitHubAuth.GetOAuthTokenViaDeviceCodeAsync(targetUri, GitHubOAuthScopes);
179184

180185
// Resolve the GitHub user handle
181186
GitHubUserInfo userInfo = await _gitHubApi.GetUserInfoAsync(targetUri, result.AccessToken);
@@ -267,12 +272,12 @@ internal async Task<AuthenticationModes> GetSupportedAuthenticationModesAsync(Ur
267272
if (StringComparer.OrdinalIgnoreCase.Equals(metaInfo.InstalledVersion, GitHubConstants.GitHubAeVersionString))
268273
{
269274
// Assume all GHAE instances have the GCM OAuth application deployed
270-
modes |= AuthenticationModes.Browser;
275+
modes |= AuthenticationModes.OAuth;
271276
}
272277
else if (Version.TryParse(metaInfo.InstalledVersion, out var version) && version >= GitHubConstants.MinimumOnPremOAuthVersion)
273278
{
274279
// Only GHES versions beyond the minimum version have the GCM OAuth application deployed
275-
modes |= AuthenticationModes.Browser;
280+
modes |= AuthenticationModes.OAuth;
276281
}
277282

278283
Context.Trace.WriteLine($"GitHub Enterprise instance has version '{metaInfo.InstalledVersion}' and supports authentication schemes: {modes}");

0 commit comments

Comments
 (0)