diff --git a/docs/README.md b/docs/README.md index 3076e1935..3c08cfdfc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ The following are links to GCM user support documentation: - [Host provider specification][gcm-host-provider] - [Azure Repos OAuth tokens][gcm-azure-tokens] - [GitLab support][gcm-gitlab] +- [Generic OAuth support][gcm-oauth] [gcm-azure-tokens]: azrepos-users-and-tokens.md [gcm-config]: configuration.md @@ -23,4 +24,5 @@ The following are links to GCM user support documentation: [gcm-gitlab]: gitlab.md [gcm-host-provider]: hostprovider.md [gcm-net-config]: netconfig.md -[gcm-usage]: usage.md \ No newline at end of file +[gcm-oauth]: generic-oauth.md +[gcm-usage]: usage.md diff --git a/docs/generic-oauth.md b/docs/generic-oauth.md new file mode 100644 index 000000000..6620134fc --- /dev/null +++ b/docs/generic-oauth.md @@ -0,0 +1,116 @@ +# Generic Host Provider OAuth + +Many Git hosts use the popular standard OAuth2 or OpenID Connect (OIDC) +authentication mechanisms to secure repositories they host. +Git Credential Manager supports any generic OAuth2-based Git host by simply +setting some configuration. + +## Registering an OAuth application + +In order to use GCM with a Git host that supports OAuth you must first have +registered an OAuth application with your host. The instructions on how to do +this can be found with your Git host provider's documentation. + +When registering a new application, you should make sure to set an HTTP-based +redirect URL that points to `localhost`; for example: + +```text +http://localhost +http://localhost: +http://127.0.0.1 +http://127.0.0.1: +``` + +Note that you cannot use an HTTPS redirect URL. GCM does not require a specific +port number be used; if your Git host requires you to specify a port number in +the redirect URL then GCM will use that. Otherwise an available port will be +selected at the point authentication starts. + +You must ensure that all scopes required to read and write to Git repositories +have been granted for the application or else credentials that are generated +will cause errors when pushing or fetching using Git. + +As part of the registration process you should also be given a Client ID and, +optionally, a Client Secret. You will need both of these to configure GCM. + +## Configure GCM + +In order to configure GCM to use OAuth with your Git host you need to set the +following values in your Git configuration: + +- Client ID +- Client Secret (optional) +- Redirect URL +- Scopes (optional) +- OAuth Endpoints + - Authorization Endpoint + - Token Endpoint + - Device Code Authorization Endpoint (optional) + +OAuth endpoints can be found by consulting your Git host's OAuth app development +documentation. The URLs can be either absolute or relative to the host name; +for example: `https://example.com/oauth/authorize` or `/oauth/authorize`. + +In order to set these values, you can run the following commands, where `` +is the hostname of your Git host: + +```shell +git config --global credential..oauthClientId +git config --global credential..oauthClientSecret +git config --global credential..oauthRedirectUri +git config --global credential..oauthAuthorizeEndpoint +git config --global credential..oauthTokenEndpoint +git config --global credential..oauthScopes +git config --global credential..oauthDeviceEndpoint +``` + +**Example commands:** + +- `git config --global credential.https://example.com.oauthClientId C33F2751FB76` + +- `git config --global credential.https://example.com.oauthScopes "code:write profile:read"` + +**Example Git configuration** + +```ini +[credential "https://example.com"] + oauthClientId = 9d886e36-5771-4f2b-8c8b-420c68ad5baa + oauthClientSecret = 4BC5BD4704EAE28FD832 + oauthRedirectUri = "http://127.0.0.1" + oauthAuthorizeEndpoint = "/login/oauth/authorize" + oauthTokenEndpoint = "/login/oauth/token" + oauthDeviceEndpoint = "/login/oauth/device" + oauthScopes = "code:write profile:read" + oauthDefaultUserName = "OAUTH" + oauthUseClientAuthHeader = false +``` + +### Additional configuration + +Depending on the specific implementation of OAuth with your Git host you may +also need to specify additional behavior. + +#### Token user name + +If your Git host requires that you specify a username to use with OAuth tokens +you can either include the username in the Git remote URL, or specify a default +option via Git configuration. + +Example Git remote with username: `https://username@example.com/repo.git`. +In order to use special characters you need to URL encode the values; for +example `@` becomes `%40`. + +By default GCM uses the value `OAUTH-USER` unless specified in the remote URL, +or overriden using the `credential..oauthDefaultUserName` configuration. + +#### Include client authentication in headers + +If your Git host's OAuth implementation has specific requirements about whether +the client ID and secret should or should not be included in an `Authorization` +header during OAuth requests, you can control this using the following setting: + +```shell +git config --global credential..oauthUseClientAuthHeader +``` + +The default behavior is to include these values; i.e., `true`. diff --git a/src/shared/Core.Tests/GenericHostProviderTests.cs b/src/shared/Core.Tests/GenericHostProviderTests.cs index 42dc62177..39ed85cfe 100644 --- a/src/shared/Core.Tests/GenericHostProviderTests.cs +++ b/src/shared/Core.Tests/GenericHostProviderTests.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Threading.Tasks; using GitCredentialManager.Authentication; +using GitCredentialManager.Authentication.OAuth; using GitCredentialManager.Tests.Objects; using Moq; using Xunit; @@ -87,8 +89,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 +124,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 +156,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); @@ -182,6 +187,90 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotSupported_Retu await TestCreateCredentialAsync_ReturnsBasicCredential(wiaSupported: false); } + [Fact] + public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAuthConfig_UsesOAuth() + { + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "git.example.com", + ["path"] = "foo" + }); + + const string testUserName = "TEST_OAUTH_USER"; + const string testAcessToken = "OAUTH_TOKEN"; + const string testRefreshToken = "OAUTH_REFRESH_TOKEN"; + const string testResource = "https://git.example.com/foo"; + const string expectedRefreshTokenService = "https://refresh_token.git.example.com/foo"; + + var authMode = OAuthAuthenticationModes.Browser; + string[] scopes = { "code:write", "code:read" }; + string clientId = "3eadfc62-9e91-45d3-8c60-20ccd6d0c7cf"; + string clientSecret = "C1DA8B93CCB5F5B93DA"; + string redirectUri = "http://localhost"; + string authzEndpoint = "/oauth/authorize"; + string tokenEndpoint = "/oauth/token"; + string deviceEndpoint = "/oauth/device"; + + string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}"; + + var context = new TestCommandContext + { + Git = + { + Configuration = + { + Global = + { + [GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { clientId }, + [GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { clientSecret }, + [GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { redirectUri }, + [GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', scopes) }, + [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[] { testUserName }, + } + } + }, + Settings = + { + RemoteUri = new Uri(testResource) + } + }; + + var basicAuthMock = new Mock(); + var wiaAuthMock = new Mock(); + var oauthMock = new Mock(); + oauthMock.Setup(x => + x.GetAuthenticationModeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(authMode); + oauthMock.Setup(x => x.GetTokenByBrowserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new OAuth2TokenResult(testAcessToken, "access_token") + { + Scopes = scopes, + RefreshToken = testRefreshToken + }); + + var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); + + ICredential credential = await provider.GenerateCredentialAsync(input); + + Assert.NotNull(credential); + Assert.Equal(testUserName, credential.Account); + Assert.Equal(testAcessToken, credential.Password); + + Assert.True(context.CredentialStore.TryGet(expectedRefreshTokenService, null, out TestCredential refreshToken)); + Assert.Equal(testUserName, refreshToken.Account); + Assert.Equal(testRefreshToken, refreshToken.Password); + + oauthMock.Verify(x => x.GetAuthenticationModeAsync(testResource, OAuthAuthenticationModes.All), Times.Once); + oauthMock.Verify(x => x.GetTokenByBrowserAsync(It.IsAny(), scopes), Times.Once); + oauthMock.Verify(x => x.GetTokenByDeviceCodeAsync(It.IsAny(), scopes), Times.Never); + wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny()), Times.Never); + basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny(), It.IsAny()), Times.Never); + } + #region Helpers private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool wiaSupported) @@ -199,8 +288,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 +320,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.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.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/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 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; } + } +} diff --git a/src/shared/Core/Authentication/OAuthAuthentication.cs b/src/shared/Core/Authentication/OAuthAuthentication.cs new file mode 100644 index 000000000..8da18faad --- /dev/null +++ b/src/shared/Core/Authentication/OAuthAuthentication.cs @@ -0,0 +1,219 @@ +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; + } + + if (Context.Settings.IsGuiPromptsEnabled && Context.SessionManager.IsDesktopSession && + TryFindHelperCommand(out string command, out string args)) + { + var promptArgs = new StringBuilder(args); + promptArgs.Append("oauth"); + + if (!string.IsNullOrWhiteSpace(resource)) + { + promptArgs.AppendFormat(" --resource {0}", QuoteCmdArg(resource)); + } + + if ((modes & OAuthAuthenticationModes.Browser) != 0) + { + promptArgs.Append(" --browser"); + } + + if ((modes & OAuthAuthenticationModes.DeviceCode) != 0) + { + promptArgs.Append(" --device-code"); + } + + IDictionary resultDict = await InvokeHelperAsync(command, promptArgs.ToString()); + + if (!resultDict.TryGetValue("mode", out string responseMode)) + { + throw new Exception("Missing 'mode' in response"); + } + + switch (responseMode.ToLowerInvariant()) + { + case "browser": + return OAuthAuthenticationModes.Browser; + + case "devicecode": + return OAuthAuthenticationModes.DeviceCode; + + default: + throw new Exception($"Unknown mode value in response '{responseMode}'"); + } + } + else + { + 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); + + // If we have a desktop session show the device code in a dialog + if (Context.Settings.IsGuiPromptsEnabled && Context.SessionManager.IsDesktopSession && + TryFindHelperCommand(out string command, out string args)) + { + var promptArgs = new StringBuilder(args); + promptArgs.Append("device"); + promptArgs.AppendFormat(" --code {0} ", QuoteCmdArg(dcr.UserCode)); + promptArgs.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(command, promptArgs.ToString(), null, promptCts.Token); + + // Start the request for an OAuth token but don't wait + Task tokenTask = client.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) + { + tokenCts.Cancel(); + } + + OAuth2TokenResult tokenResult; + try + { + tokenResult = await tokenTask; + } + catch (OperationCanceledException) + { + throw new Exception("User canceled device code authentication"); + } + + // Close the dialog + promptCts.Cancel(); + + return tokenResult; + } + else + { + 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); + } + } + + private bool TryFindHelperCommand(out string command, out string args) + { + return TryFindHelperCommand( + Constants.EnvironmentVariables.GcmUiHelper, + Constants.GitConfiguration.Credential.UiHelper, + Constants.DefaultUiHelper, + out command, + out args); + } + } +} 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..2b05537e1 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,24 +13,27 @@ 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; } - #region HostProvider - public override string Id => "generic"; public override string Name => "Generic"; @@ -50,12 +56,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}"); + + return await GetOAuthAccessToken(uri, input.UserName, oauthConfig); } + // Try detecting WIA for this remote, if permitted else if (IsWindowsAuthAllowed) { if (PlatformUtils.IsWindows()) @@ -86,10 +109,103 @@ 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); } + 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); + + // + // 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( + 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!"); + } + + // 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); + } + /// /// Check if the user permits checking for Windows Integrated Authentication. /// @@ -115,12 +231,14 @@ private bool IsWindowsAuthAllowed } } + private HttpClient _httpClient; + private HttpClient HttpClient => _httpClient ?? (_httpClient = Context.HttpClientFactory.CreateClient()); + protected override void ReleaseManagedResources() { _winAuth.Dispose(); + _httpClient?.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/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 @@