Skip to content

Commit

Permalink
github: implement explicit device code flow option
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mjcheetham committed Oct 6, 2021
1 parent 0fdb03a commit 28f487a
Show file tree
Hide file tree
Showing 4 changed files with 105 additions and 29 deletions.
6 changes: 3 additions & 3 deletions src/shared/GitHub.Tests/GitHubHostProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes(string uriS
[Theory]
[InlineData("https://example.com", null, "0.1", false, AuthenticationModes.Pat)]
[InlineData("https://example.com", null, "0.1", true, AuthenticationModes.Basic | AuthenticationModes.Pat)]
[InlineData("https://example.com", null, "100.0", false, AuthenticationModes.Browser | AuthenticationModes.Pat)]
[InlineData("https://example.com", null, "100.0", false, AuthenticationModes.OAuth | AuthenticationModes.Pat)]
[InlineData("https://example.com", null, "100.0", true, AuthenticationModes.All)]
public async Task GitHubHostProvider_GetSupportedAuthenticationModes_WithMetadata(string uriString, string gitHubAuthModes,
string installedVersion, bool verifiablePasswordAuthentication, AuthenticationModes expectedModes)
Expand Down Expand Up @@ -196,7 +196,7 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Browser_ReturnsCred
ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny<AuthenticationModes>()))
.ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.Browser));

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

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

ghAuthMock.Verify(
x => x.GetOAuthTokenAsync(
x => x.GetOAuthTokenViaBrowserAsync(
expectedTargetUri, expectedOAuthScopes),
Times.Once);
}
Expand Down
111 changes: 91 additions & 20 deletions src/shared/GitHub/GitHubAuthentication.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Text;
using System.Threading;
Expand All @@ -16,7 +17,9 @@ public interface IGitHubAuthentication : IDisposable

Task<string> GetTwoFactorCodeAsync(Uri targetUri, bool isSms);

Task<OAuth2TokenResult> GetOAuthTokenAsync(Uri targetUri, IEnumerable<string> scopes);
Task<OAuth2TokenResult> GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable<string> scopes);

Task<OAuth2TokenResult> GetOAuthTokenViaDeviceCodeAsync(Uri targetUri, IEnumerable<string> scopes);
}

public class AuthenticationPromptResult
Expand All @@ -43,9 +46,11 @@ public enum AuthenticationModes
None = 0,
Basic = 1,
Browser = 1 << 1,
Pat = 1 << 2,
Pat = 1 << 2,
Device = 1 << 3,

All = Basic | Browser | Pat
OAuth = Browser | Device,
All = Basic | OAuth | Pat
}

public class GitHubAuthentication : AuthenticationBase, IGitHubAuthentication
Expand All @@ -62,6 +67,13 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
{
ThrowIfUserInteractionDisabled();

// If we don't have a desktop session/GUI then we cannot offer browser
if (!Context.SessionManager.IsDesktopSession)
{
modes = modes & ~AuthenticationModes.Browser;
}

// We need at least one mode!
if (modes == AuthenticationModes.None)
{
throw new ArgumentException(@$"Must specify at least one {nameof(AuthenticationModes)}", nameof(modes));
Expand All @@ -78,6 +90,7 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
{
if ((modes & AuthenticationModes.Basic) != 0) promptArgs.Append(" --basic");
if ((modes & AuthenticationModes.Browser) != 0) promptArgs.Append(" --browser");
if ((modes & AuthenticationModes.Device) != 0) promptArgs.Append(" --device");
if ((modes & AuthenticationModes.Pat) != 0) promptArgs.Append(" --pat");
}
if (!GitHubHostProvider.IsGitHubDotCom(targetUri)) promptArgs.AppendFormat(" --enterprise-url {0}", QuoteCmdArg(targetUri.ToString()));
Expand All @@ -104,6 +117,9 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
case "browser":
return new AuthenticationPromptResult(AuthenticationModes.Browser);

case "device":
return new AuthenticationPromptResult(AuthenticationModes.Device);

case "basic":
if (!resultDict.TryGetValue("username", out userName))
{
Expand Down Expand Up @@ -148,6 +164,9 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
case AuthenticationModes.Browser:
return new AuthenticationPromptResult(AuthenticationModes.Browser);

case AuthenticationModes.Device:
return new AuthenticationPromptResult(AuthenticationModes.Device);

case AuthenticationModes.Pat:
Context.Terminal.WriteLine("Enter GitHub personal access token for '{0}'...", targetUri);
string pat = Context.Terminal.PromptSecret("Token");
Expand All @@ -162,17 +181,20 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
var menu = new TerminalMenu(Context.Terminal, menuTitle);

TerminalMenuItem browserItem = null;
TerminalMenuItem deviceItem = null;
TerminalMenuItem basicItem = null;
TerminalMenuItem patItem = null;

if ((modes & AuthenticationModes.Browser) != 0) browserItem = menu.Add("Web browser");
if ((modes & AuthenticationModes.Device) != 0) deviceItem = menu.Add("Device code");
if ((modes & AuthenticationModes.Pat) != 0) patItem = menu.Add("Personal access token");
if ((modes & AuthenticationModes.Basic) != 0) basicItem = menu.Add("Username/password");

// Default to the 'first' choice in the menu
TerminalMenuItem choice = menu.Show(0);

if (choice == browserItem) goto case AuthenticationModes.Browser;
if (choice == deviceItem) goto case AuthenticationModes.Device;
if (choice == basicItem) goto case AuthenticationModes.Basic;
if (choice == patItem) goto case AuthenticationModes.Pat;

Expand Down Expand Up @@ -218,41 +240,90 @@ public async Task<string> GetTwoFactorCodeAsync(Uri targetUri, bool isSms)
}
}

public async Task<OAuth2TokenResult> GetOAuthTokenAsync(Uri targetUri, IEnumerable<string> scopes)
public async Task<OAuth2TokenResult> GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable<string> scopes)
{
ThrowIfUserInteractionDisabled();

var oauthClient = new GitHubOAuth2Client(HttpClient, Context.Settings, targetUri);

// If we have a desktop session try authentication using the user's default web browser
if (Context.SessionManager.IsDesktopSession)
// We require a desktop session to launch the user's default web browser
if (!Context.SessionManager.IsDesktopSession)
{
throw new InvalidOperationException("Browser authentication requires a desktop session");
}

var browserOptions = new OAuth2WebBrowserOptions
{
var browserOptions = new OAuth2WebBrowserOptions
SuccessResponseHtml = GitHubResources.AuthenticationResponseSuccessHtml,
FailureResponseHtmlFormat = GitHubResources.AuthenticationResponseFailureHtmlFormat
};
var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions);

// Write message to the terminal (if any is attached) for some feedback that we're waiting for a web response
Context.Terminal.WriteLine("info: please complete authentication in your browser...");

OAuth2AuthorizationCodeResult authCodeResult =
await oauthClient.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None);

return await oauthClient.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None);
}

public async Task<OAuth2TokenResult> GetOAuthTokenViaDeviceCodeAsync(Uri targetUri, IEnumerable<string> scopes)
{
ThrowIfUserInteractionDisabled();

var oauthClient = new GitHubOAuth2Client(HttpClient, Context.Settings, targetUri);
OAuth2DeviceCodeResult dcr = await oauthClient.GetDeviceCodeAsync(scopes, CancellationToken.None);

// If we have a desktop session show the device code in a dialog
if (Context.SessionManager.IsDesktopSession && TryFindHelperExecutablePath(out string helperPath))
{
var args = new StringBuilder("device");
args.AppendFormat(" --code {0} ", QuoteCmdArg(dcr.UserCode));
args.AppendFormat(" --url {0}", QuoteCmdArg(dcr.VerificationUri.ToString()));

var promptCts = new CancellationTokenSource();
var tokenCts = new CancellationTokenSource();

// Show the dialog with the device code but don't await its closure
Task promptTask = InvokeHelperAsync(helperPath, args.ToString(), null, promptCts.Token);

// Start the request for an OAuth token but don't wait
Task<OAuth2TokenResult> tokenTask = oauthClient.GetTokenByDeviceCodeAsync(dcr, tokenCts.Token);

Task t = await Task.WhenAny(promptTask, tokenTask);

// If the dialog was closed the user wishes to cancel the request
if (t == promptTask)
{
SuccessResponseHtml = GitHubResources.AuthenticationResponseSuccessHtml,
FailureResponseHtmlFormat = GitHubResources.AuthenticationResponseFailureHtmlFormat
};
var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions);
tokenCts.Cancel();
}

// Write message to the terminal (if any is attached) for some feedback that we're waiting for a web response
Context.Terminal.WriteLine("info: please complete authentication in your browser...");
OAuth2TokenResult tokenResult;
try
{
tokenResult = await tokenTask;
}
catch (OperationCanceledException)
{
throw new Exception("User canceled device code authentication");
}

OAuth2AuthorizationCodeResult authCodeResult = await oauthClient.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None);
// Close the dialog
promptCts.Cancel();

return await oauthClient.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None);
return tokenResult;
}
else
{
ThrowIfTerminalPromptsDisabled();

OAuth2DeviceCodeResult deviceCodeResult = await oauthClient.GetDeviceCodeAsync(scopes, CancellationToken.None);

string deviceMessage = $"To complete authentication please visit {deviceCodeResult.VerificationUri} and enter the following code:" +
string deviceMessage = $"To complete authentication please visit {dcr.VerificationUri} and enter the following code:" +
Environment.NewLine +
deviceCodeResult.UserCode;
dcr.UserCode;
Context.Terminal.WriteLine(deviceMessage);

return await oauthClient.GetTokenByDeviceCodeAsync(deviceCodeResult, CancellationToken.None);
return await oauthClient.GetTokenByDeviceCodeAsync(dcr, CancellationToken.None);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/shared/GitHub/GitHubConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public static class GitHubConstants
/// As of 13th November 2020, GitHub.com does not support username/password (basic) authentication to the APIs.
/// See https://developer.github.com/changes/2020-02-14-deprecating-oauth-auth-endpoint for more information.
/// </remarks>
public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.Browser | AuthenticationModes.Pat;
public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.OAuth | AuthenticationModes.Pat;

public static class TokenScopes
{
Expand Down
15 changes: 10 additions & 5 deletions src/shared/GitHub/GitHubHostProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,10 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
return patCredential;

case AuthenticationModes.Browser:
return await GenerateOAuthCredentialAsync(remoteUri);
return await GenerateOAuthCredentialAsync(remoteUri, useBrowser: true);

case AuthenticationModes.Device:
return await GenerateOAuthCredentialAsync(remoteUri, useBrowser: false);

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

private async Task<GitCredential> GenerateOAuthCredentialAsync(Uri targetUri)
private async Task<GitCredential> GenerateOAuthCredentialAsync(Uri targetUri, bool useBrowser)
{
OAuth2TokenResult result = await _gitHubAuth.GetOAuthTokenAsync(targetUri, GitHubOAuthScopes);
OAuth2TokenResult result = useBrowser
? await _gitHubAuth.GetOAuthTokenViaBrowserAsync(targetUri, GitHubOAuthScopes)
: await _gitHubAuth.GetOAuthTokenViaDeviceCodeAsync(targetUri, GitHubOAuthScopes);

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

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

0 comments on commit 28f487a

Please sign in to comment.