diff --git a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs index d21a3699b..46639ee36 100644 --- a/src/shared/GitHub.Tests/GitHubHostProviderTests.cs +++ b/src/shared/GitHub.Tests/GitHubHostProviderTests.cs @@ -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) @@ -196,7 +196,7 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Browser_ReturnsCred ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny())) .ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.Browser)); - ghAuthMock.Setup(x => x.GetOAuthTokenAsync(expectedTargetUri, It.IsAny>())) + ghAuthMock.Setup(x => x.GetOAuthTokenViaBrowserAsync(expectedTargetUri, It.IsAny>())) .ReturnsAsync(response); var ghApiMock = new Mock(MockBehavior.Strict); @@ -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); } diff --git a/src/shared/GitHub/GitHubAuthentication.cs b/src/shared/GitHub/GitHubAuthentication.cs index 9bb6c8a85..1d127f91a 100644 --- a/src/shared/GitHub/GitHubAuthentication.cs +++ b/src/shared/GitHub/GitHubAuthentication.cs @@ -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; @@ -16,7 +17,9 @@ public interface IGitHubAuthentication : IDisposable Task GetTwoFactorCodeAsync(Uri targetUri, bool isSms); - Task GetOAuthTokenAsync(Uri targetUri, IEnumerable scopes); + Task GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable scopes); + + Task GetOAuthTokenViaDeviceCodeAsync(Uri targetUri, IEnumerable scopes); } public class AuthenticationPromptResult @@ -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 @@ -62,6 +67,13 @@ public async Task 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)); @@ -78,6 +90,7 @@ public async Task 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())); @@ -104,6 +117,9 @@ public async Task 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)) { @@ -148,6 +164,9 @@ public async Task 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"); @@ -162,10 +181,12 @@ public async Task 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"); @@ -173,6 +194,7 @@ public async Task GetAuthenticationAsync(Uri targetU 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; @@ -218,41 +240,90 @@ public async Task GetTwoFactorCodeAsync(Uri targetUri, bool isSms) } } - public async Task GetOAuthTokenAsync(Uri targetUri, IEnumerable scopes) + public async Task GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable 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 GetOAuthTokenViaDeviceCodeAsync(Uri targetUri, IEnumerable 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 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); } } diff --git a/src/shared/GitHub/GitHubConstants.cs b/src/shared/GitHub/GitHubConstants.cs index ea3a58a71..39725bbc7 100644 --- a/src/shared/GitHub/GitHubConstants.cs +++ b/src/shared/GitHub/GitHubConstants.cs @@ -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. /// - public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.Browser | AuthenticationModes.Pat; + public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.OAuth | AuthenticationModes.Pat; public static class TokenScopes { diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 46f21bf4a..8f699734c 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -150,7 +150,10 @@ public override async Task 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 @@ -173,9 +176,11 @@ public override async Task GenerateCredentialAsync(InputArguments i } } - private async Task GenerateOAuthCredentialAsync(Uri targetUri) + private async Task 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); @@ -267,12 +272,12 @@ internal async Task 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}");