From e9ee7641f37df0f03655f37d0a39e8b1df6dc723 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 27 Jan 2023 11:44:14 -0800 Subject: [PATCH 01/10] basic-ui: fix bug in VM property Fix a bug in a view model property for the basic credentials prompt; we should be updating the backing field and also raising the PropertyChanged event. --- src/shared/Core.UI/ViewModels/CredentialsViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs b/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs index c93c8ff29..7f40b8bea 100644 --- a/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs +++ b/src/shared/Core.UI/ViewModels/CredentialsViewModel.cs @@ -56,7 +56,7 @@ public string Description public bool ShowProductHeader { get => _showProductHeader; - set => _showProductHeader = value; + set => SetAndRaisePropertyChanged(ref _showProductHeader, value); } public RelayCommand SignInCommand From 717b8225f431b093399b385104eade0d8b3cf957 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 27 Jan 2023 11:49:01 -0800 Subject: [PATCH 02/10] generic: add ability to read generic OAuth config Teach the Generic host provider to read configuration for OAuth-based authentication. These are largely parameters required for the OAuth2Client to be constructed including Client ID/Secret, Redirect URI and Scopes. --- .../Core.Tests/GenericOAuthConfigTests.cs | 61 ++++++++ src/shared/Core/Constants.cs | 21 +++ src/shared/Core/GenericHostProvider.cs | 26 +++- src/shared/Core/GenericOAuthConfig.cs | 138 ++++++++++++++++++ .../Objects/TestSettings.cs | 34 ++++- 5 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 src/shared/Core.Tests/GenericOAuthConfigTests.cs create mode 100644 src/shared/Core/GenericOAuthConfig.cs diff --git a/src/shared/Core.Tests/GenericOAuthConfigTests.cs b/src/shared/Core.Tests/GenericOAuthConfigTests.cs new file mode 100644 index 000000000..08dfacab4 --- /dev/null +++ b/src/shared/Core.Tests/GenericOAuthConfigTests.cs @@ -0,0 +1,61 @@ +using System; +using GitCredentialManager.Tests.Objects; +using Xunit; + +namespace GitCredentialManager.Tests +{ + public class GenericOAuthConfigTests + { + [Fact] + public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue() + { + var remoteUri = new Uri("https://example.com"); + const string expectedClientId = "115845b0-77f8-4c06-a3dc-7d277381fad1"; + const string expectedClientSecret = "4D35385D9F24"; + const string expectedUserName = "TEST_USER"; + const string authzEndpoint = "/oauth/authorize"; + const string tokenEndpoint = "/oauth/token"; + const string deviceEndpoint = "/oauth/device"; + string[] expectedScopes = { "scope1", "scope2" }; + var expectedRedirectUri = new Uri("http://localhost:12345"); + var expectedAuthzEndpoint = new Uri(remoteUri, authzEndpoint); + var expectedTokenEndpoint = new Uri(remoteUri, tokenEndpoint); + var expectedDeviceEndpoint = new Uri(remoteUri, deviceEndpoint); + + string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}"; + + var trace = new NullTrace(); + var settings = new TestSettings + { + GitConfiguration = new TestGitConfiguration + { + Global = + { + [GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { expectedClientId }, + [GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { expectedClientSecret }, + [GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { expectedRedirectUri.ToString() }, + [GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', expectedScopes) }, + [GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { authzEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { tokenEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthDeviceEndpoint)] = new[] { deviceEndpoint }, + [GetKey(Constants.GitConfiguration.Credential.OAuthDefaultUserName)] = new[] { expectedUserName }, + } + }, + RemoteUri = remoteUri + }; + + bool result = GenericOAuthConfig.TryGet(trace, settings, remoteUri, out GenericOAuthConfig config); + + Assert.True(result); + Assert.Equal(expectedClientId, config.ClientId); + Assert.Equal(expectedClientSecret, config.ClientSecret); + Assert.Equal(expectedRedirectUri, config.RedirectUri); + Assert.Equal(expectedScopes, config.Scopes); + Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint); + Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint); + Assert.Equal(expectedDeviceEndpoint, config.Endpoints.DeviceAuthorizationEndpoint); + Assert.Equal(expectedUserName, config.DefaultUserName); + Assert.True(config.UseAuthHeader); + } + } +} diff --git a/src/shared/Core/Constants.cs b/src/shared/Core/Constants.cs index 54c8b1246..b8c9fa750 100644 --- a/src/shared/Core/Constants.cs +++ b/src/shared/Core/Constants.cs @@ -89,6 +89,16 @@ public static class EnvironmentVariables public const string GcmAutoDetectTimeout = "GCM_AUTODETECT_TIMEOUT"; public const string GcmGuiPromptsEnabled = "GCM_GUI_PROMPT"; public const string GcmUiHelper = "GCM_UI_HELPER"; + public const string OAuthAuthenticationModes = "GCM_OAUTH_AUTHMODES"; + public const string OAuthClientId = "GCM_OAUTH_CLIENTID"; + public const string OAuthClientSecret = "GCM_OAUTH_CLIENTSECRET"; + public const string OAuthRedirectUri = "GCM_OAUTH_REDIRECTURI"; + public const string OAuthScopes = "GCM_OAUTH_SCOPES"; + public const string OAuthAuthzEndpoint = "GCM_OAUTH_AUTHORIZE_ENDPOINT"; + public const string OAuthTokenEndpoint = "GCM_OAUTH_TOKEN_ENDPOINT"; + public const string OAuthDeviceEndpoint = "GCM_OAUTH_DEVICE_ENDPOINT"; + public const string OAuthClientAuthHeader = "GCM_OAUTH_USE_CLIENT_AUTH_HEADER"; + public const string OAuthDefaultUserName = "GCM_OAUTH_DEFAULT_USERNAME"; } public static class Http @@ -125,6 +135,17 @@ public static class Credential public const string AutoDetectTimeout = "autoDetectTimeout"; public const string GuiPromptsEnabled = "guiPrompt"; public const string UiHelper = "uiHelper"; + + public const string OAuthAuthenticationModes = "oauthAuthModes"; + public const string OAuthClientId = "oauthClientId"; + public const string OAuthClientSecret = "oauthClientSecret"; + public const string OAuthRedirectUri = "oauthRedirectUri"; + public const string OAuthScopes = "oauthScopes"; + public const string OAuthAuthzEndpoint = "oauthAuthorizeEndpoint"; + public const string OAuthTokenEndpoint = "oauthTokenEndpoint"; + public const string OAuthDeviceEndpoint = "oauthDeviceEndpoint"; + public const string OAuthClientAuthHeader = "oauthUseClientAuthHeader"; + public const string OAuthDefaultUserName = "oauthDefaultUserName"; } public static class Http diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 3f98eab0f..18f288def 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -26,8 +26,6 @@ public GenericHostProvider(ICommandContext context) _winAuth = winAuth; } - #region HostProvider - public override string Id => "generic"; public override string Name => "Generic"; @@ -50,12 +48,29 @@ public override async Task GenerateCredentialAsync(InputArguments i Uri uri = input.GetRemoteUri(); - // Determine the if the host supports Windows Integration Authentication (WIA) + // Determine the if the host supports Windows Integration Authentication (WIA) or OAuth if (!StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "http") && !StringComparer.OrdinalIgnoreCase.Equals(uri.Scheme, "https")) { - // Cannot check WIA support for non-HTTP based protocols + // Cannot check WIA or OAuth support for non-HTTP based protocols + } + // Check for an OAuth configuration for this remote + else if (GenericOAuthConfig.TryGet(Context.Trace, Context.Settings, uri, out GenericOAuthConfig oauthConfig)) + { + Context.Trace.WriteLine($"Found generic OAuth configuration for '{uri}':"); + Context.Trace.WriteLine($"\tAuthzEndpoint = {oauthConfig.Endpoints.AuthorizationEndpoint}"); + Context.Trace.WriteLine($"\tTokenEndpoint = {oauthConfig.Endpoints.TokenEndpoint}"); + Context.Trace.WriteLine($"\tDeviceEndpoint = {oauthConfig.Endpoints.DeviceAuthorizationEndpoint}"); + Context.Trace.WriteLine($"\tClientId = {oauthConfig.ClientId}"); + Context.Trace.WriteLine($"\tClientSecret = {oauthConfig.ClientSecret}"); + Context.Trace.WriteLine($"\tRedirectUri = {oauthConfig.RedirectUri}"); + Context.Trace.WriteLine($"\tScopes = [{string.Join(", ", oauthConfig.Scopes)}]"); + Context.Trace.WriteLine($"\tUseAuthHeader = {oauthConfig.UseAuthHeader}"); + Context.Trace.WriteLine($"\tDefaultUserName = {oauthConfig.DefaultUserName}"); + + throw new NotImplementedException(); } + // Try detecting WIA for this remote, if permitted else if (IsWindowsAuthAllowed) { if (PlatformUtils.IsWindows()) @@ -86,6 +101,7 @@ public override async Task GenerateCredentialAsync(InputArguments i Context.Trace.WriteLine("Windows Integrated Authentication detection has been disabled."); } + // Use basic authentication Context.Trace.WriteLine("Prompting for basic credentials..."); return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName); } @@ -120,7 +136,5 @@ protected override void ReleaseManagedResources() _winAuth.Dispose(); base.ReleaseManagedResources(); } - - #endregion } } diff --git a/src/shared/Core/GenericOAuthConfig.cs b/src/shared/Core/GenericOAuthConfig.cs new file mode 100644 index 000000000..0e2a74b75 --- /dev/null +++ b/src/shared/Core/GenericOAuthConfig.cs @@ -0,0 +1,138 @@ +using System; +using GitCredentialManager.Authentication.OAuth; + +namespace GitCredentialManager +{ + public class GenericOAuthConfig + { + public static bool TryGet(ITrace trace, ISettings settings, Uri remoteUri, out GenericOAuthConfig config) + { + config = new GenericOAuthConfig(); + + if (!settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthAuthzEndpoint, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthAuthzEndpoint, + out string authzEndpoint) || + !Uri.TryCreate(remoteUri, authzEndpoint, out Uri authzEndpointUri)) + { + trace.WriteLine($"Invalid OAuth configuration - missing/invalid authorize endpoint: {authzEndpoint}"); + config = null; + return false; + } + + if (!settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthTokenEndpoint, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthTokenEndpoint, + out string tokenEndpoint) || + !Uri.TryCreate(remoteUri, tokenEndpoint, out Uri tokenEndpointUri)) + { + trace.WriteLine($"Invalid OAuth configuration - missing/invalid token endpoint: {tokenEndpoint}"); + config = null; + return false; + } + + // Device code endpoint is optional + Uri deviceEndpointUri = null; + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthDeviceEndpoint, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthDeviceEndpoint, + out string deviceEndpoint)) + { + if (!Uri.TryCreate(remoteUri, deviceEndpoint, out deviceEndpointUri)) + { + trace.WriteLine($"Invalid OAuth configuration - invalid device endpoint: {deviceEndpoint}"); + } + } + + config.Endpoints = new OAuth2ServerEndpoints(authzEndpointUri, tokenEndpointUri) + { + DeviceAuthorizationEndpoint = deviceEndpointUri + }; + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthClientId, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthClientId, + out string clientId)) + { + config.ClientId = clientId; + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthClientSecret, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthClientSecret, + out string clientSecret)) + { + config.ClientSecret = clientSecret; + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthRedirectUri, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthRedirectUri, + out string redirectUrl) && + Uri.TryCreate(redirectUrl, UriKind.Absolute, out Uri redirectUri)) + { + config.RedirectUri = redirectUri; + } + else + { + trace.WriteLine($"Invalid OAuth configuration - missing/invalid redirect URI: {redirectUrl}"); + config = null; + return false; + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthScopes, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthScopes, + out string scopesStr) && !string.IsNullOrWhiteSpace(scopesStr)) + { + config.Scopes = scopesStr.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + } + else + { + config.Scopes = Array.Empty(); + } + + if (settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthClientAuthHeader, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthClientAuthHeader, + out string useHeader)) + { + config.UseAuthHeader = useHeader.IsTruthy(); + } + else + { + // Default to true + config.UseAuthHeader = true; + } + + config.DefaultUserName = settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthDefaultUserName, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthDefaultUserName, + out string userName) + ? userName + : "OAUTH_USER"; + + return true; + } + + + public OAuth2ServerEndpoints Endpoints { get; set; } + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public Uri RedirectUri { get; set; } + public string[] Scopes { get; set; } + public bool UseAuthHeader { get; set; } + public string DefaultUserName { get; set; } + + public bool SupportsDeviceCode => Endpoints.DeviceAuthorizationEndpoint != null; + } +} diff --git a/src/shared/TestInfrastructure/Objects/TestSettings.cs b/src/shared/TestInfrastructure/Objects/TestSettings.cs index 73a6ec37c..a7ea19abb 100644 --- a/src/shared/TestInfrastructure/Objects/TestSettings.cs +++ b/src/shared/TestInfrastructure/Objects/TestSettings.cs @@ -58,6 +58,18 @@ public bool TryGetSetting(string envarName, string section, string property, out return true; } + if (RemoteUri != null) + { + foreach (string scope in RemoteUri.GetGitConfigurationScopes()) + { + string key = $"{section}.{scope}.{property}"; + if (GitConfiguration?.TryGet(key, false, out value) ?? false) + { + return true; + } + } + } + if (GitConfiguration?.TryGet($"{section}.{property}", false, out value) ?? false) { return true; @@ -79,16 +91,26 @@ public IEnumerable GetSettingValues(string envarName, string section, st yield return envarValue; } - foreach (string scope in RemoteUri.GetGitConfigurationScopes()) + IEnumerable configValues; + if (RemoteUri != null) { - string key = $"{section}.{scope}.{property}"; - - IEnumerable configValues = GitConfiguration.GetAll(key); - foreach (string value in configValues) + foreach (string scope in RemoteUri.GetGitConfigurationScopes()) { - yield return value; + string key = $"{section}.{scope}.{property}"; + + configValues = GitConfiguration.GetAll(key); + foreach (string value in configValues) + { + yield return value; + } } } + + configValues = GitConfiguration.GetAll($"{section}.{property}"); + foreach (string value in configValues) + { + yield return value; + } } public string RepositoryPath { get; set; } From 720a078b2203e40ef079a176ca7832cfbc4f1c67 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 27 Jan 2023 11:56:32 -0800 Subject: [PATCH 03/10] generic: add OAuth support for browser & devicecode Add OAuth support for the generic provider offering browser (authcode grant) and device code (device auth grant) support. Device code and mode selection is initially only offered for TTY users. --- .../Core.Tests/GenericHostProviderTests.cs | 15 ++- .../Authentication/OAuthAuthentication.cs | 123 ++++++++++++++++++ src/shared/Core/GenericHostProvider.cs | 77 ++++++++++- 3 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/shared/Core/Authentication/OAuthAuthentication.cs diff --git a/src/shared/Core.Tests/GenericHostProviderTests.cs b/src/shared/Core.Tests/GenericHostProviderTests.cs index 42dc62177..19d6aa1eb 100644 --- a/src/shared/Core.Tests/GenericHostProviderTests.cs +++ b/src/shared/Core.Tests/GenericHostProviderTests.cs @@ -87,8 +87,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -121,8 +122,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -152,8 +154,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu .ReturnsAsync(basicCredential) .Verifiable(); var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -199,8 +202,9 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool var wiaAuthMock = new Mock(); wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny())) .ReturnsAsync(wiaSupported); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); @@ -230,8 +234,9 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool var wiaAuthMock = new Mock(); wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny())) .ReturnsAsync(wiaSupported); + var oauthMock = new Mock(); - var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object); + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); ICredential credential = await provider.GenerateCredentialAsync(input); diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs new file mode 100644 index 000000000..959719dfd --- /dev/null +++ b/src/shared/Core/Authentication/OAuthAuthentication.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.Authentication.OAuth; + +namespace GitCredentialManager.Authentication +{ + [Flags] + public enum OAuthAuthenticationModes + { + None = 0, + Browser = 1 << 0, + DeviceCode = 1 << 1, + + All = Browser | DeviceCode + } + + public interface IOAuthAuthentication + { + Task GetAuthenticationModeAsync(string resource, OAuthAuthenticationModes modes); + + Task GetTokenByBrowserAsync(OAuth2Client client, string[] scopes); + + Task GetTokenByDeviceCodeAsync(OAuth2Client client, string[] scopes); + } + + public class OAuthAuthentication : AuthenticationBase, IOAuthAuthentication + { + public OAuthAuthentication(ICommandContext context) + : base (context) { } + + public async Task GetAuthenticationModeAsync( + string resource, OAuthAuthenticationModes modes) + { + EnsureArgument.NotNullOrWhiteSpace(resource, nameof(resource)); + + ThrowIfUserInteractionDisabled(); + + // Browser requires a desktop session! + if (!Context.SessionManager.IsDesktopSession) + { + modes &= ~OAuthAuthenticationModes.Browser; + } + + // We need at least one mode! + if (modes == OAuthAuthenticationModes.None) + { + throw new ArgumentException(@$"Must specify at least one {nameof(OAuthAuthenticationModes)}", nameof(modes)); + } + + // If there is no mode choice to be made then just return that result + if (modes == OAuthAuthenticationModes.Browser || + modes == OAuthAuthenticationModes.DeviceCode) + { + return modes; + } + + ThrowIfTerminalPromptsDisabled(); + + switch (modes) + { + case OAuthAuthenticationModes.Browser: + return OAuthAuthenticationModes.Browser; + + case OAuthAuthenticationModes.DeviceCode: + return OAuthAuthenticationModes.DeviceCode; + + default: + var menuTitle = $"Select an authentication method for '{resource}'"; + var menu = new TerminalMenu(Context.Terminal, menuTitle); + + TerminalMenuItem browserItem = null; + TerminalMenuItem deviceItem = null; + + if ((modes & OAuthAuthenticationModes.Browser) != 0) browserItem = menu.Add("Web browser"); + if ((modes & OAuthAuthenticationModes.DeviceCode) != 0) deviceItem = menu.Add("Device code"); + + // Default to the 'first' choice in the menu + TerminalMenuItem choice = menu.Show(0); + + if (choice == browserItem) goto case OAuthAuthenticationModes.Browser; + if (choice == deviceItem) goto case OAuthAuthenticationModes.DeviceCode; + + throw new Exception(); + } + + } + + public async Task GetTokenByBrowserAsync(OAuth2Client client, string[] scopes) + { + ThrowIfUserInteractionDisabled(); + + // 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 browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions); + var authCode = await client.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None); + return await client.GetTokenByAuthorizationCodeAsync(authCode, CancellationToken.None); + } + + public async Task GetTokenByDeviceCodeAsync(OAuth2Client client, string[] scopes) + { + ThrowIfUserInteractionDisabled(); + + OAuth2DeviceCodeResult dcr = await client.GetDeviceCodeAsync(scopes, CancellationToken.None); + + ThrowIfTerminalPromptsDisabled(); + + string deviceMessage = $"To complete authentication please visit {dcr.VerificationUri} and enter the following code:" + + Environment.NewLine + + dcr.UserCode; + Context.Terminal.WriteLine(deviceMessage); + + return await client.GetTokenByDeviceCodeAsync(dcr, CancellationToken.None); + } + } +} diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 18f288def..845cb3ccc 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -1,8 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using GitCredentialManager.Authentication; +using GitCredentialManager.Authentication.OAuth; namespace GitCredentialManager { @@ -10,20 +13,25 @@ public class GenericHostProvider : HostProvider { private readonly IBasicAuthentication _basicAuth; private readonly IWindowsIntegratedAuthentication _winAuth; + private readonly IOAuthAuthentication _oauth; public GenericHostProvider(ICommandContext context) - : this(context, new BasicAuthentication(context), new WindowsIntegratedAuthentication(context)) { } + : this(context, new BasicAuthentication(context), new WindowsIntegratedAuthentication(context), + new OAuthAuthentication(context)) { } public GenericHostProvider(ICommandContext context, IBasicAuthentication basicAuth, - IWindowsIntegratedAuthentication winAuth) + IWindowsIntegratedAuthentication winAuth, + IOAuthAuthentication oauth) : base(context) { EnsureArgument.NotNull(basicAuth, nameof(basicAuth)); EnsureArgument.NotNull(winAuth, nameof(winAuth)); + EnsureArgument.NotNull(oauth, nameof(oauth)); _basicAuth = basicAuth; _winAuth = winAuth; + _oauth = oauth; } public override string Id => "generic"; @@ -68,7 +76,7 @@ public override async Task GenerateCredentialAsync(InputArguments i Context.Trace.WriteLine($"\tUseAuthHeader = {oauthConfig.UseAuthHeader}"); Context.Trace.WriteLine($"\tDefaultUserName = {oauthConfig.DefaultUserName}"); - throw new NotImplementedException(); + return await GetOAuthAccessToken(uri, input.UserName, oauthConfig); } // Try detecting WIA for this remote, if permitted else if (IsWindowsAuthAllowed) @@ -106,6 +114,65 @@ public override async Task GenerateCredentialAsync(InputArguments i return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName); } + private async Task GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config) + { + // TODO: Determined user info from a webcall? ID token? Need OIDC support + string oauthUser = userName ?? config.DefaultUserName; + + var client = new OAuth2Client( + HttpClient, + config.Endpoints, + config.ClientId, + config.RedirectUri, + config.ClientSecret, + Context.Trace, + config.UseAuthHeader); + + // Determine which interactive OAuth mode to use. Start by checking for mode preference in config + var supportedModes = OAuthAuthenticationModes.All; + if (Context.Settings.TryGetSetting( + Constants.EnvironmentVariables.OAuthAuthenticationModes, + Constants.GitConfiguration.Credential.SectionName, + Constants.GitConfiguration.Credential.OAuthAuthenticationModes, + out string authModesStr)) + { + if (Enum.TryParse(authModesStr, true, out supportedModes) && supportedModes != OAuthAuthenticationModes.None) + { + Context.Trace.WriteLine($"Supported authentication modes override present: {supportedModes}"); + } + else + { + Context.Trace.WriteLine($"Invalid value for supported authentication modes override setting: '{authModesStr}'"); + } + } + + // If the server doesn't support device code we need to remove it as an option here + if (!config.SupportsDeviceCode) + { + supportedModes &= ~OAuthAuthenticationModes.DeviceCode; + } + + // Prompt the user to select a mode + OAuthAuthenticationModes mode = await _oauth.GetAuthenticationModeAsync(remoteUri.ToString(), supportedModes); + + OAuth2TokenResult tokenResult; + switch (mode) + { + case OAuthAuthenticationModes.Browser: + tokenResult = await _oauth.GetTokenByBrowserAsync(client, config.Scopes); + break; + + case OAuthAuthenticationModes.DeviceCode: + tokenResult = await _oauth.GetTokenByDeviceCodeAsync(client, config.Scopes); + break; + + default: + throw new Exception("No authentication mode selected!"); + } + + return new GitCredential(oauthUser, tokenResult.AccessToken); + } + /// /// Check if the user permits checking for Windows Integrated Authentication. /// @@ -131,9 +198,13 @@ private bool IsWindowsAuthAllowed } } + private HttpClient _httpClient; + private HttpClient HttpClient => _httpClient ?? (_httpClient = Context.HttpClientFactory.CreateClient()); + protected override void ReleaseManagedResources() { _winAuth.Dispose(); + _httpClient?.Dispose(); base.ReleaseManagedResources(); } } From e323c8345762e0082ad2c40d9f5c54b6872050e2 Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 27 Jan 2023 11:58:17 -0800 Subject: [PATCH 04/10] generic: add OAuth refresh token support Add support for storing and using OAuth refresh tokens. Prepend "refresh_token" as a subdomain to give better chances of avoiding a name clash compared with appending "/refresh_token" to the path component. --- src/shared/Core/GenericHostProvider.cs | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 845cb3ccc..2b05537e1 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -128,6 +128,33 @@ private async Task GetOAuthAccessToken(Uri remoteUri, string userNa Context.Trace, config.UseAuthHeader); + // + // Prepend "refresh_token" to the hostname to get a (hopefully) unique service name that + // doesn't clash with an existing credential service. + // + // Appending "/refresh_token" to the end of the remote URI may not always result in a unique + // service because users may set credential.useHttpPath and include "/refresh_token" as a + // path name. + // + string refreshService = new UriBuilder(remoteUri) { Host = $"refresh_token.{remoteUri.Host}" } + .Uri.AbsoluteUri.TrimEnd('/'); + + // Try to use a refresh token if we have one + ICredential refreshToken = Context.CredentialStore.Get(refreshService, userName); + if (refreshToken != null) + { + var refreshResult = await client.GetTokenByRefreshTokenAsync(refreshToken.Password, CancellationToken.None); + + // Store new refresh token if we have been given one + if (!string.IsNullOrWhiteSpace(refreshResult.RefreshToken)) + { + Context.CredentialStore.AddOrUpdate(refreshService, refreshToken.Account, refreshToken.Password); + } + + // Return the new access token + return new GitCredential(oauthUser,refreshResult.AccessToken); + } + // Determine which interactive OAuth mode to use. Start by checking for mode preference in config var supportedModes = OAuthAuthenticationModes.All; if (Context.Settings.TryGetSetting( @@ -170,6 +197,12 @@ private async Task GetOAuthAccessToken(Uri remoteUri, string userNa throw new Exception("No authentication mode selected!"); } + // Store the refresh token if we have one + if (!string.IsNullOrWhiteSpace(tokenResult.RefreshToken)) + { + Context.CredentialStore.AddOrUpdate(refreshService, oauthUser, tokenResult.RefreshToken); + } + return new GitCredential(oauthUser, tokenResult.AccessToken); } From 6702935004094f352c1ef168a500af129d936ecd Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 27 Jan 2023 11:59:24 -0800 Subject: [PATCH 05/10] oauth-ui: Add shared VMs and commands for OAuth Add shared view models and commands for the OAuth GUI prompts. Two new commands and VMs are added, one for the initial 'mode' selection, and another to display the device code. --- .../Core.UI/Commands/DeviceCodeCommand.cs | 52 +++++++++++ src/shared/Core.UI/Commands/OAuthCommand.cs | 93 +++++++++++++++++++ .../Core.UI/ViewModels/DeviceCodeViewModel.cs | 58 ++++++++++++ .../Core.UI/ViewModels/OAuthViewModel.cs | 70 ++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 src/shared/Core.UI/Commands/DeviceCodeCommand.cs create mode 100644 src/shared/Core.UI/Commands/OAuthCommand.cs create mode 100644 src/shared/Core.UI/ViewModels/DeviceCodeViewModel.cs create mode 100644 src/shared/Core.UI/ViewModels/OAuthViewModel.cs diff --git a/src/shared/Core.UI/Commands/DeviceCodeCommand.cs b/src/shared/Core.UI/Commands/DeviceCodeCommand.cs new file mode 100644 index 000000000..863e24a1d --- /dev/null +++ b/src/shared/Core.UI/Commands/DeviceCodeCommand.cs @@ -0,0 +1,52 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.UI.ViewModels; + +namespace GitCredentialManager.UI.Commands +{ + public abstract class DeviceCodeCommand : HelperCommand + { + protected DeviceCodeCommand(ICommandContext context) + : base(context, "device", "Show device code prompt.") + { + AddOption( + new Option("--code", "User code.") + ); + + AddOption( + new Option("--url", "Verification URL.") + ); + + AddOption( + new Option("--no-logo", "Hide the Git Credential Manager logo and logotype.") + ); + + Handler = CommandHandler.Create(ExecuteAsync); + } + + private async Task ExecuteAsync(string code, string url, bool noLogo) + { + var viewModel = new DeviceCodeViewModel(Context.Environment) + { + UserCode = code, + VerificationUrl = url, + }; + + viewModel.ShowProductHeader = !noLogo; + + await ShowAsync(viewModel, CancellationToken.None); + + if (!viewModel.WindowResult) + { + throw new Exception("User cancelled dialog."); + } + + return 0; + } + + protected abstract Task ShowAsync(DeviceCodeViewModel viewModel, CancellationToken ct); + } +} diff --git a/src/shared/Core.UI/Commands/OAuthCommand.cs b/src/shared/Core.UI/Commands/OAuthCommand.cs new file mode 100644 index 000000000..aa48d7eb1 --- /dev/null +++ b/src/shared/Core.UI/Commands/OAuthCommand.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.Authentication; +using GitCredentialManager.UI.ViewModels; + +namespace GitCredentialManager.UI.Commands +{ + public abstract class OAuthCommand : HelperCommand + { + protected OAuthCommand(ICommandContext context) + : base(context, "oauth", "Show OAuth authentication prompt.") + { + AddOption( + new Option("--title", "Window title (optional).") + ); + + AddOption( + new Option("--resource", "Resource name or URL (optional).") + ); + + AddOption( + new Option("--browser", "Show browser authentication option.") + ); + + AddOption( + new Option("--device-code", "Show device code authentication option.") + ); + + AddOption( + new Option("--no-logo", "Hide the Git Credential Manager logo and logotype.") + ); + + Handler = CommandHandler.Create(ExecuteAsync); + } + + private class CommandOptions + { + public string Title { get; set; } + public string Resource { get; set; } + public bool Browser { get; set; } + public bool DeviceCode { get; set; } + public bool NoLogo { get; set; } + } + + private async Task ExecuteAsync(CommandOptions options) + { + var viewModel = new OAuthViewModel(); + + viewModel.Title = !string.IsNullOrWhiteSpace(options.Title) + ? options.Title + : "Git Credential Manager"; + + viewModel.Description = !string.IsNullOrWhiteSpace(options.Resource) + ? $"Sign in to '{options.Resource}'" + : "Select a sign-in option"; + + viewModel.ShowBrowserLogin = options.Browser; + viewModel.ShowDeviceCodeLogin = options.DeviceCode; + viewModel.ShowProductHeader = !options.NoLogo; + + await ShowAsync(viewModel, CancellationToken.None); + + if (!viewModel.WindowResult) + { + throw new Exception("User cancelled dialog."); + } + + var result = new Dictionary(); + switch (viewModel.SelectedMode) + { + case OAuthAuthenticationModes.Browser: + result["mode"] = "browser"; + break; + + case OAuthAuthenticationModes.DeviceCode: + result["mode"] = "devicecode"; + break; + + default: + throw new ArgumentOutOfRangeException(); + } + + WriteResult(result); + return 0; + } + + protected abstract Task ShowAsync(OAuthViewModel viewModel, CancellationToken ct); + } +} diff --git a/src/shared/Core.UI/ViewModels/DeviceCodeViewModel.cs b/src/shared/Core.UI/ViewModels/DeviceCodeViewModel.cs new file mode 100644 index 000000000..d44a9f2c7 --- /dev/null +++ b/src/shared/Core.UI/ViewModels/DeviceCodeViewModel.cs @@ -0,0 +1,58 @@ +using System.Windows.Input; + +namespace GitCredentialManager.UI.ViewModels +{ + public class DeviceCodeViewModel : WindowViewModel + { + private readonly IEnvironment _environment; + + private ICommand _verificationUrlCommand; + private string _verificationUrl; + private string _userCode; + private bool _showProductHeader; + + public DeviceCodeViewModel() + { + // Constructor the XAML designer + } + + public DeviceCodeViewModel(IEnvironment environment) + { + EnsureArgument.NotNull(environment, nameof(environment)); + + _environment = environment; + + Title = "Device code authentication"; + VerificationUrlCommand = new RelayCommand(OpenVerificationUrl); + } + + private void OpenVerificationUrl() + { + BrowserUtils.OpenDefaultBrowser(_environment, VerificationUrl); + } + + public string UserCode + { + get => _userCode; + set => SetAndRaisePropertyChanged(ref _userCode, value); + } + + public string VerificationUrl + { + get => _verificationUrl; + set => SetAndRaisePropertyChanged(ref _verificationUrl, value); + } + + public ICommand VerificationUrlCommand + { + get => _verificationUrlCommand; + set => SetAndRaisePropertyChanged(ref _verificationUrlCommand, value); + } + + public bool ShowProductHeader + { + get => _showProductHeader; + set => SetAndRaisePropertyChanged(ref _showProductHeader, value); + } + } +} diff --git a/src/shared/Core.UI/ViewModels/OAuthViewModel.cs b/src/shared/Core.UI/ViewModels/OAuthViewModel.cs new file mode 100644 index 000000000..607642f31 --- /dev/null +++ b/src/shared/Core.UI/ViewModels/OAuthViewModel.cs @@ -0,0 +1,70 @@ +using GitCredentialManager.Authentication; + +namespace GitCredentialManager.UI.ViewModels +{ + public class OAuthViewModel : WindowViewModel + { + private string _description; + private bool _showProductHeader; + private bool _showBrowserLogin; + private bool _showDeviceCodeLogin; + private RelayCommand _signInBrowserCommand; + private RelayCommand _signInDeviceCodeCommand; + + public OAuthViewModel() + { + SignInBrowserCommand = new RelayCommand(SignInWithBrowser); + SignInDeviceCodeCommand = new RelayCommand(SignInWithDeviceCode); + } + + private void SignInWithBrowser() + { + SelectedMode = OAuthAuthenticationModes.Browser; + Accept(); + } + + private void SignInWithDeviceCode() + { + SelectedMode = OAuthAuthenticationModes.DeviceCode; + Accept(); + } + + public string Description + { + get => _description; + set => SetAndRaisePropertyChanged(ref _description, value); + } + + public bool ShowProductHeader + { + get => _showProductHeader; + set => SetAndRaisePropertyChanged(ref _showProductHeader, value); + } + + public bool ShowBrowserLogin + { + get => _showBrowserLogin; + set => SetAndRaisePropertyChanged(ref _showBrowserLogin, value); + } + + public bool ShowDeviceCodeLogin + { + get => _showDeviceCodeLogin; + set => SetAndRaisePropertyChanged(ref _showDeviceCodeLogin, value); + } + + public RelayCommand SignInBrowserCommand + { + get => _signInBrowserCommand; + set => SetAndRaisePropertyChanged(ref _signInBrowserCommand, value); + } + + public RelayCommand SignInDeviceCodeCommand + { + get => _signInDeviceCodeCommand; + set => SetAndRaisePropertyChanged(ref _signInDeviceCodeCommand, value); + } + + public OAuthAuthenticationModes SelectedMode { get; private set; } + } +} From d59cd44768cbdc5956e7a3e2b2010214c0dd6efa Mon Sep 17 00:00:00 2001 From: Matthew John Cheetham Date: Fri, 27 Jan 2023 12:16:25 -0800 Subject: [PATCH 06/10] generic-ui: add Avalonia impl of OAuth and Device Code Add AvaloniaUI based implementations of the OAuth and Device Code generic UI prompts. --- .../Commands/DeviceCodeCommandImpl.cs | 17 ++++++ .../Commands/OAuthCommandImpl.cs | 17 ++++++ .../Controls/TesterWindow.axaml | 49 +++++++++++++++++ .../Controls/TesterWindow.axaml.cs | 54 +++++++++++++++++++ .../Git-Credential-Manager.UI.Avalonia.csproj | 8 +++ .../Program.cs | 2 + .../Views/DeviceCodeView.axaml | 44 +++++++++++++++ .../Views/DeviceCodeView.axaml.cs | 18 +++++++ .../Views/OAuthView.axaml | 49 +++++++++++++++++ .../Views/OAuthView.axaml.cs | 18 +++++++ 10 files changed, 276 insertions(+) create mode 100644 src/shared/Git-Credential-Manager.UI.Avalonia/Commands/DeviceCodeCommandImpl.cs create mode 100644 src/shared/Git-Credential-Manager.UI.Avalonia/Commands/OAuthCommandImpl.cs create mode 100644 src/shared/Git-Credential-Manager.UI.Avalonia/Views/DeviceCodeView.axaml create mode 100644 src/shared/Git-Credential-Manager.UI.Avalonia/Views/DeviceCodeView.axaml.cs create mode 100644 src/shared/Git-Credential-Manager.UI.Avalonia/Views/OAuthView.axaml create mode 100644 src/shared/Git-Credential-Manager.UI.Avalonia/Views/OAuthView.axaml.cs diff --git a/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/DeviceCodeCommandImpl.cs b/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/DeviceCodeCommandImpl.cs new file mode 100644 index 000000000..045dcab15 --- /dev/null +++ b/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/DeviceCodeCommandImpl.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.UI.ViewModels; +using GitCredentialManager.UI.Views; + +namespace GitCredentialManager.UI.Commands +{ + public class DeviceCodeCommandImpl : DeviceCodeCommand + { + public DeviceCodeCommandImpl(ICommandContext context) : base(context) { } + + protected override Task ShowAsync(DeviceCodeViewModel viewModel, CancellationToken ct) + { + return AvaloniaUi.ShowViewAsync(viewModel, GetParentHandle(), ct); + } + } +} diff --git a/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/OAuthCommandImpl.cs b/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/OAuthCommandImpl.cs new file mode 100644 index 000000000..536cda52a --- /dev/null +++ b/src/shared/Git-Credential-Manager.UI.Avalonia/Commands/OAuthCommandImpl.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; +using GitCredentialManager.UI.ViewModels; +using GitCredentialManager.UI.Views; + +namespace GitCredentialManager.UI.Commands +{ + public class OAuthCommandImpl : OAuthCommand + { + public OAuthCommandImpl(ICommandContext context) : base(context) { } + + protected override Task ShowAsync(OAuthViewModel viewModel, CancellationToken ct) + { + return AvaloniaUi.ShowViewAsync(viewModel, GetParentHandle(), ct); + } + } +} diff --git a/src/shared/Git-Credential-Manager.UI.Avalonia/Controls/TesterWindow.axaml b/src/shared/Git-Credential-Manager.UI.Avalonia/Controls/TesterWindow.axaml index e10e5c401..4f96254d6 100644 --- a/src/shared/Git-Credential-Manager.UI.Avalonia/Controls/TesterWindow.axaml +++ b/src/shared/Git-Credential-Manager.UI.Avalonia/Controls/TesterWindow.axaml @@ -45,5 +45,54 @@