diff --git a/Git-Credential-Manager.sln b/Git-Credential-Manager.sln index 39248b52c..e07dfae0f 100644 --- a/Git-Credential-Manager.sln +++ b/Git-Credential-Manager.sln @@ -70,6 +70,7 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Git-Credential-Manager.UI.Avalonia", "src\shared\Git-Credential-Manager.UI.Avalonia\Git-Credential-Manager.UI.Avalonia.csproj", "{35659127-8859-4DB9-8DD6-A08C1952632E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Git-Credential-Manager.UI.Windows", "src\windows\Git-Credential-Manager.UI.Windows\Git-Credential-Manager.UI.Windows.csproj", "{01BF56EC-AAC1-4BCA-8204-EE51D968DF5C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gitea", "src\shared\Gitea\Gitea.csproj", "{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -415,6 +416,23 @@ Global {570897DC-A85C-4598-B793-9A00CF710119}.MacRelease|Any CPU.Build.0 = Release|Any CPU {570897DC-A85C-4598-B793-9A00CF710119}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU {570897DC-A85C-4598-B793-9A00CF710119}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacDebug|Any CPU.Build.0 = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Release|Any CPU.Build.0 = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacRelease|Any CPU.Build.0 = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU + {FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29} {1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.Debug|Any CPU.Build.0 = Debug|Any CPU {1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU diff --git a/README.md b/README.md index a97a7b79d..13f0856ad 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Compared to Git's [built-in credential helpers][git-tools-credential-storage] provides single-factor authentication support working on any HTTP-enabled Git repository, GCM provides multi-factor authentication support for [Azure DevOps][azure-devops], Azure DevOps Server (formerly Team Foundation -Server), GitHub, Bitbucket, and GitLab. +Server), GitHub, Bitbucket, GitLab and Gitea. Git Credential Manager (GCM) replaces the .NET Framework-based [Git Credential Manager for Windows][gcm-for-windows] (GCM), and the Java-based @@ -36,6 +36,7 @@ Multi-factor authentication support for Azure DevOps|✓|✓|✓ Two-factor authentication support for GitHub|✓|✓|✓ Two-factor authentication support for Bitbucket|✓|✓|✓ Two-factor authentication support for GitLab|✓|✓|✓ +Two-factor authentication support for Gitea|✓|✓|✓ Windows Integrated Authentication (NTLM/Kerberos) support|✓|_N/A_|_N/A_ Basic HTTP authentication support|✓|✓|✓ Proxy support|✓|✓|✓ @@ -283,6 +284,7 @@ See detailed information [here][gcm-http-proxy]. - [Host provider specification][gcm-host-provider] - [Azure Repos OAuth tokens][gcm-azure-tokens] - [GitLab support][gcm-gitlab] +- [Gitea support][gcm-gitea] ## Experimental Features @@ -322,6 +324,7 @@ When using GitHub logos, please be sure to follow the [gcm-for-mac-and-linux]: https://github.com/microsoft/Git-Credential-Manager-for-Mac-and-Linux [gcm-for-windows]: https://github.com/microsoft/Git-Credential-Manager-for-Windows [gcm-gitlab]: docs/gitlab.md +[gcm-gitea]: docs/gitea.md [gcm-host-provider]: docs/hostprovider.md [gcm-http-proxy]: docs/netconfig.md#http-proxy [gcm-license]: LICENSE diff --git a/docs/configuration.md b/docs/configuration.md index 656169691..8de0a45e1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,6 +87,7 @@ ID|Provider `github`|GitHub `bitbucket`|Bitbucket `gitlab`|GitLab _(supports OAuth in browser, personal access token and Basic Authentication)_ +`gitea`|Gitea _(supports OAuth in browser, personal access token and Basic Authentication)_ `generic`|Generic (any other provider not listed above) Automatic provider selection is based on the remote URL. @@ -350,6 +351,31 @@ git config --global credential.namespace "my-namespace" --- +### credential.giteaAuthModes + +Override the available authentication modes presented during Gitea +authentication. If this option is not set, then the available authentication +modes will be automatically detected. + +**Note:** This setting supports multiple values separated by commas. + +Value|Authentication Mode +-|- +_(unset)_|Automatically detect modes +`browser`|OAuth authentication via a web browser _(requires a GUI)_ +`basic`|Basic authentication using username and password +`pat`|Personal Access Token (pat)-based authentication + +#### Example + +```shell +git config --global credential.giteaAuthModes "browser" +``` + +**Also see: [GCM_GITEA_AUTHMODES][gcm-gitea-authmodes]** + +--- + ### credential.credentialStore Select the type of credential store to use on supported platforms. @@ -611,6 +637,7 @@ git config --global credential.azreposCredentialType oauth [gcm-dpapi-store-path]: environment.md#GCM_DPAPI_STORE_PATH [gcm-github-authmodes]: environment.md#GCM_GITHUB_AUTHMODES [gcm-gitlab-authmodes]:environment.md#GCM_GITLAB_AUTHMODES +[gcm-gitea-authmodes]:environment.md#GCM_GITEA_AUTHMODES [gcm-gui-prompt]: environment.md#GCM_GUI_PROMPT [gcm-http-proxy]: environment.md#GCM_HTTP_PROXY-deprecated [gcm-interactive]: environment.md#GCM_INTERACTIVE diff --git a/docs/environment.md b/docs/environment.md index cb70baf27..6d22c42b4 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -183,6 +183,7 @@ ID|Provider `azure-repos`|Azure Repos `github`|GitHub `gitlab`|GitLab _(supports OAuth in browser, personal access token and Basic Authentication)_ +`gitea`|Gitea _(supports OAuth in browser, personal access token and Basic Authentication)_ `generic`|Generic (any other provider not listed above) Automatic provider selection is based on the remote URL. @@ -498,6 +499,37 @@ export GCM_GITLAB_AUTHMODES="browser" --- +### GCM_GITEA_AUTHMODES + +Override the available authentication modes presented during Gitea +authentication. If this option is not set, then the available authentication +modes will be automatically detected. + +**Note:** This setting supports multiple values separated by commas. + +Value|Authentication Mode +-|- +_(unset)_|Automatically detect modes +`browser`|OAuth authentication via a web browser _(requires a GUI)_ +`basic`|Basic authentication using username and password +`pat`|Personal Access Token (pat)-based authentication + +#### Windows + +```batch +SET GCM_GITEA_AUTHMODES="browser" +``` + +#### macOS/Linux + +```bash +export GCM_GITEA_AUTHMODES="browser" +``` + +**Also see: [credential.giteaAuthModes][credential-giteaauthmodes]** + +--- + ### GCM_NAMESPACE Use a custom namespace prefix for credentials read and written in the OS @@ -752,6 +784,7 @@ export GCM_AZREPOS_CREDENTIALTYPE="oauth" [credential-dpapi-store-path]: configuration.md#credentialdpapistorepath [credential-githubauthmodes]: configuration.md#credentialgitHubAuthModes [credential-gitlabauthmodes]: configuration.md#credentialgitLabAuthModes +[credential-giteaauthmodes]: configuration.md#credentialgiteaAuthModes [credential-guiprompt]: configuration.md#credentialguiprompt [credential-httpproxy]: configuration.md#credentialhttpProxy-deprecated [credential-interactive]: configuration.md#credentialinteractive diff --git a/docs/gitea.md b/docs/gitea.md new file mode 100644 index 000000000..70b789baf --- /dev/null +++ b/docs/gitea.md @@ -0,0 +1,48 @@ +# Gitea support + +GCM requires Gitea v1.17.3 or later. + +## Config for a custom instance + +1. In Settings / Applications, create an OAuth application, unchecking the +*Confidential* option (if this option is missing, the version of Gitea is too +old) +1. Copy the application ID and configure +`git config --global credential.https://gitea.example.com.GiteaDevClientId ` +1. Copy the application secret and configure +`git config --global credential.https://gitea.example.com.GiteaDevClientSecret +` +1. Configure authentication modes to include 'browser' +`git config --global credential.https://gitea.example.com.GiteaAuthModes browser` +1. For good measure, configure +`git config --global credential.https://gitea.example.com.provider gitea`. +This may be necessary to recognise the domain as a Gitea instance. +1. Verify the config is as expected +`git config --global --get-urlmatch credential https://gitea.example.com` + +## Clearing config + +### Clearing config + +```console + git config --global --unset-all credential.https://gitea.example.com.GiteaDevClientId + git config --global --unset-all credential.https://gitea.example.com.GiteaDevClientSecret + git config --global --unset-all credential.https://gitea.example.com.provider +``` + +## Preferences + +```console +Select an authentication method for 'https://gitea.com/': + 1. Web browser (default) + 2. Personal access token + 3. Username/password +option (enter for default): +``` + +If you have a preferred authentication mode, you can specify +[credential.giteaAuthModes][config-gitea-auth-modes]: + +```console +git config --global credential.giteaauthmodes browser +``` diff --git a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj index 441ee82bc..7a7fb0fe4 100644 --- a/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj +++ b/src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj @@ -18,6 +18,7 @@ + diff --git a/src/shared/Git-Credential-Manager/Program.cs b/src/shared/Git-Credential-Manager/Program.cs index b8ba2b2c7..4033fb11f 100644 --- a/src/shared/Git-Credential-Manager/Program.cs +++ b/src/shared/Git-Credential-Manager/Program.cs @@ -2,6 +2,7 @@ using Atlassian.Bitbucket; using GitHub; using GitLab; +using Gitea; using Microsoft.AzureRepos; using GitCredentialManager.Authentication; @@ -37,6 +38,7 @@ public static void Main(string[] args) app.RegisterProvider(new BitbucketHostProvider(context), HostProviderPriority.Normal); app.RegisterProvider(new GitHubHostProvider(context), HostProviderPriority.Normal); app.RegisterProvider(new GitLabHostProvider(context), HostProviderPriority.Normal); + app.RegisterProvider(new GiteaHostProvider(context), HostProviderPriority.Normal); app.RegisterProvider(new GenericHostProvider(context), HostProviderPriority.Low); int exitCode = app.RunAsync(args) diff --git a/src/shared/Gitea/Gitea.csproj b/src/shared/Gitea/Gitea.csproj new file mode 100644 index 000000000..7a71d54c7 --- /dev/null +++ b/src/shared/Gitea/Gitea.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + netstandard2.0;net472 + Gitea + Gitea + false + latest + + + + + + + + + + + diff --git a/src/shared/Gitea/GiteaAuthentication.cs b/src/shared/Gitea/GiteaAuthentication.cs new file mode 100644 index 000000000..72d45734d --- /dev/null +++ b/src/shared/Gitea/GiteaAuthentication.cs @@ -0,0 +1,177 @@ +using System; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using GitCredentialManager; +using GitCredentialManager.Authentication; +using GitCredentialManager.Authentication.OAuth; + +namespace Gitea +{ + public interface IGiteaAuthentication : IDisposable + { + AuthenticationPromptResult GetAuthentication(Uri targetUri, string userName, AuthenticationModes modes); + + Task GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable scopes); + + Task GetOAuthTokenViaRefresh(Uri targetUri, string refreshToken); + } + + public class AuthenticationPromptResult + { + public AuthenticationPromptResult(AuthenticationModes mode) + { + AuthenticationMode = mode; + } + + public AuthenticationPromptResult(AuthenticationModes mode, ICredential credential) + : this(mode) + { + Credential = credential; + } + + public AuthenticationModes AuthenticationMode { get; } + + public ICredential Credential { get; set; } + } + + [Flags] + public enum AuthenticationModes + { + None = 0, + Basic = 1, + Browser = 1 << 1, + Pat = 1 << 2, + + All = Basic | Browser | Pat + } + + public class GiteaAuthentication : AuthenticationBase, IGiteaAuthentication + { + public GiteaAuthentication(ICommandContext context) + : base(context) { } + + public AuthenticationPromptResult GetAuthentication(Uri targetUri, string userName, AuthenticationModes modes) + { + // 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)); + } + + { + switch (modes) + { + case AuthenticationModes.Basic: + ThrowIfUserInteractionDisabled(); + ThrowIfTerminalPromptsDisabled(); + Context.Terminal.WriteLine("Enter Gitea credentials for '{0}'...", targetUri); + + if (string.IsNullOrWhiteSpace(userName)) + { + userName = Context.Terminal.Prompt("Username"); + } + else + { + Context.Terminal.WriteLine("Username: {0}", userName); + } + + string password = Context.Terminal.PromptSecret("Password"); + return new AuthenticationPromptResult(AuthenticationModes.Basic, new GitCredential(userName, password)); + + case AuthenticationModes.Pat: + ThrowIfUserInteractionDisabled(); + ThrowIfTerminalPromptsDisabled(); + Context.Terminal.WriteLine("Enter Gitea credentials for '{0}'...", targetUri); + + if (string.IsNullOrWhiteSpace(userName)) + { + userName = Context.Terminal.Prompt("Username"); + } + else + { + Context.Terminal.WriteLine("Username: {0}", userName); + } + + string token = Context.Terminal.PromptSecret("Personal access token"); + return new AuthenticationPromptResult(AuthenticationModes.Pat, new GitCredential(userName, token)); + + case AuthenticationModes.Browser: + return new AuthenticationPromptResult(AuthenticationModes.Browser); + + case AuthenticationModes.None: + throw new ArgumentOutOfRangeException(nameof(modes), @$"At least one {nameof(AuthenticationModes)} must be supplied"); + + default: + ThrowIfUserInteractionDisabled(); + ThrowIfTerminalPromptsDisabled(); + var menuTitle = $"Select an authentication method for '{targetUri}'"; + var menu = new TerminalMenu(Context.Terminal, menuTitle); + + TerminalMenuItem browserItem = null; + TerminalMenuItem basicItem = null; + TerminalMenuItem patItem = null; + + if ((modes & AuthenticationModes.Browser) != 0) browserItem = menu.Add("Web browser"); + if ((modes & AuthenticationModes.Pat) != 0) patItem = menu.Add("Personal access token"); + if ((modes & AuthenticationModes.Basic) != 0) basicItem = menu.Add("Username/password"); + + // Default to the 'first' choice in the menu + TerminalMenuItem choice = menu.Show(0); + + if (choice == browserItem) goto case AuthenticationModes.Browser; + if (choice == basicItem) goto case AuthenticationModes.Basic; + if (choice == patItem) goto case AuthenticationModes.Pat; + + throw new Exception(); + } + } + } + + public async Task GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable scopes) + { + ThrowIfUserInteractionDisabled(); + + var oauthClient = new GiteaOAuth2Client(HttpClient, Context.Settings, targetUri); + + // 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); + + // 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 GetOAuthTokenViaRefresh(Uri targetUri, string refreshToken) + { + var oauthClient = new GiteaOAuth2Client(HttpClient, Context.Settings, targetUri); + return await oauthClient.GetTokenByRefreshTokenAsync(refreshToken, CancellationToken.None); + } + + private HttpClient _httpClient; + private HttpClient HttpClient => _httpClient ?? (_httpClient = Context.HttpClientFactory.CreateClient()); + + public void Dispose() + { + _httpClient?.Dispose(); + } + } +} diff --git a/src/shared/Gitea/GiteaConstants.cs b/src/shared/Gitea/GiteaConstants.cs new file mode 100644 index 000000000..a5cf0f72e --- /dev/null +++ b/src/shared/Gitea/GiteaConstants.cs @@ -0,0 +1,40 @@ +using System; + +namespace Gitea +{ + public static class GiteaConstants + { + public static readonly Uri OAuthRedirectUri = new Uri("http://127.0.0.1/"); + // https://docs.gitea.io/en-us/oauth2-provider/ + public static readonly Uri OAuthAuthorizationEndpointRelativeUri = new Uri("/login/oauth/authorize", UriKind.Relative); + public static readonly Uri OAuthTokenEndpointRelativeUri = new Uri("/login/oauth/access_token", UriKind.Relative); + + public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.All; + + public static class EnvironmentVariables + { + public const string DevOAuthClientId = "GCM_DEV_GITEA_CLIENTID"; + public const string DevOAuthClientSecret = "GCM_DEV_GITEA_CLIENTSECRET"; + public const string DevOAuthRedirectUri = "GCM_DEV_GITEA_REDIRECTURI"; + public const string AuthenticationModes = "GCM_GITEA_AUTHMODES"; + public const string AuthenticationHelper = "GCM_GITEA_HELPER"; + } + + public static class GitConfiguration + { + public static class Credential + { + public const string AuthenticationModes = "giteaAuthModes"; + public const string DevOAuthClientId = "giteaDevClientId"; + public const string DevOAuthClientSecret = "giteaDevClientSecret"; + public const string DevOAuthRedirectUri = "giteaDevRedirectUri"; + public const string AuthenticationHelper = "giteaHelper"; + } + } + + public static class HelpUrls + { + public const string Gitea = "https://github.com/GitCredentialManager/git-credential-manager/blob/main/docs/gitea.md"; + } + } +} diff --git a/src/shared/Gitea/GiteaHostProvider.cs b/src/shared/Gitea/GiteaHostProvider.cs new file mode 100644 index 000000000..9056d6b58 --- /dev/null +++ b/src/shared/Gitea/GiteaHostProvider.cs @@ -0,0 +1,236 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using GitCredentialManager; +using GitCredentialManager.Authentication.OAuth; +using System.Net.Http.Headers; + +namespace Gitea +{ + public class GiteaHostProvider : HostProvider + { + private static readonly string[] GiteaOAuthScopes = + { + }; + + private readonly IGiteaAuthentication _giteaAuth; + + public GiteaHostProvider(ICommandContext context) + : this(context, new GiteaAuthentication(context)) { } + + public GiteaHostProvider(ICommandContext context, IGiteaAuthentication giteaAuth) + : base(context) + { + EnsureArgument.NotNull(giteaAuth, nameof(giteaAuth)); + + _giteaAuth = giteaAuth; + } + + public override string Id => "gitea"; + + public override string Name => "Gitea"; + + public override bool IsSupported(InputArguments input) + { + if (input is null) + { + return false; + } + + // We do not support unencrypted HTTP communications to Gitea, + // but we report `true` here for HTTP so that we can show a helpful + // error message for the user in `CreateCredentialAsync`. + if (!StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") && + !StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) + { + return false; + } + + // Split port number and hostname from host input argument + if (!input.TryGetHostAndPort(out string hostName, out _)) + { + return false; + } + + string[] domains = hostName.Split(new char[] { '.' }); + + // Gitea[.subdomain].domain.tld + if (domains.Length >= 3 && + StringComparer.OrdinalIgnoreCase.Equals(domains[0], "gitea")) + { + return true; + } + + return false; + } + + public override bool IsSupported(HttpResponseMessage response) => + response?.Headers.Any(pair => pair.Key == "Set-Cookie" && pair.Value.Any(x => x.Contains("i_like_gitea="))) ?? false; + + public override async Task GenerateCredentialAsync(InputArguments input) + { + ThrowIfDisposed(); + + Uri remoteUri = input.GetRemoteUri(); + + AuthenticationModes authModes = GetSupportedAuthenticationModes(remoteUri); + + AuthenticationPromptResult promptResult = _giteaAuth.GetAuthentication(remoteUri, input.UserName, authModes); + + switch (promptResult.AuthenticationMode) + { + case AuthenticationModes.Basic: + case AuthenticationModes.Pat: + return promptResult.Credential; + + case AuthenticationModes.Browser: + return await GenerateOAuthCredentialAsync(input); + + default: + throw new ArgumentOutOfRangeException(nameof(promptResult)); + } + } + + internal AuthenticationModes GetSupportedAuthenticationModes(Uri targetUri) + { + // Check for an explicit override for supported authentication modes + if (Context.Settings.TryGetSetting( + GiteaConstants.EnvironmentVariables.AuthenticationModes, + Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.AuthenticationModes, + out string authModesStr)) + { + if (Enum.TryParse(authModesStr, true, out AuthenticationModes authModes) && authModes != AuthenticationModes.None) + { + Context.Trace.WriteLine($"Supported authentication modes override present: {authModes}"); + return authModes; + } + else + { + Context.Trace.WriteLine($"Invalid value for supported authentication modes override setting: '{authModesStr}'"); + } + } + + // Try to detect what auth modes are available for this non-Gitea.com host. + // Assume that PATs are always available to give at least one option to users! + var modes = AuthenticationModes.Pat; + + // If there is a configured OAuth client ID + // then assume OAuth is possible. + string oauthClientId = GiteaOAuth2Client.GetClientId(Context.Settings); + if (oauthClientId != null) { + modes |= AuthenticationModes.Browser; + } else { + // Tell the user that they may wish to configure OAuth for this Gitea instance + Context.Streams.Error.WriteLine( + $"warning: missing OAuth configuration for {targetUri.Host} - see {GiteaConstants.HelpUrls.Gitea} for more information"); + } + + // assume password auth is always available. + bool supportsBasic = true; + if (supportsBasic) + { + modes |= AuthenticationModes.Basic; + } + + return modes; + } + + // Stores OAuth tokens as a side effect + public override async Task GetCredentialAsync(InputArguments input) + { + string service = GetServiceName(input); + ICredential credential = Context.CredentialStore.Get(service, input.UserName); + if (credential?.Account == "oauth2" && IsOAuthTokenExpired(input.GetRemoteUri(), credential.Password)) + { + Context.Trace.WriteLine("Removing (possibly) expired OAuth access token..."); + Context.CredentialStore.Remove(service, credential.Account); + credential = null; + } + + if (credential != null) + { + return credential; + } + + string refreshService = GetRefreshTokenServiceName(input); + string refreshToken = Context.CredentialStore.Get(refreshService, input.UserName)?.Password; + if (refreshToken != null) + { + Context.Trace.WriteLine("Refreshing OAuth token..."); + try + { + credential = await RefreshOAuthCredentialAsync(input, refreshToken); + } + catch (Exception e) + { + Context.Terminal.WriteLine($"OAuth token refresh failed: {e.Message}"); + } + } + + credential ??= await GenerateCredentialAsync(input); + + if (credential is OAuthCredential oAuthCredential) + { + Context.Trace.WriteLine("Pre-emptively storing OAuth access and refresh tokens..."); + // freshly-generated OAuth credential + // store credential, since we know it to be valid (whereas Git will only store credential if git push succeeds) + Context.CredentialStore.AddOrUpdate(service, oAuthCredential.Account, oAuthCredential.AccessToken); + // store refresh token under a separate service + Context.CredentialStore.AddOrUpdate(refreshService, oAuthCredential.Account, oAuthCredential.RefreshToken); + } + return credential; + } + + private bool IsOAuthTokenExpired(Uri baseUri, string accessToken) + { + return true; + } + + internal class OAuthCredential : ICredential + { + public OAuthCredential(OAuth2TokenResult oAuth2TokenResult) + { + AccessToken = oAuth2TokenResult.AccessToken; + RefreshToken = oAuth2TokenResult.RefreshToken; + } + + public string Account => "oauth2"; + public string AccessToken { get; } + public string RefreshToken { get; } + string ICredential.Password => AccessToken; + } + + private async Task GenerateOAuthCredentialAsync(InputArguments input) + { + OAuth2TokenResult result = await _giteaAuth.GetOAuthTokenViaBrowserAsync(input.GetRemoteUri(), GiteaOAuthScopes); + return new OAuthCredential(result); + } + + private async Task RefreshOAuthCredentialAsync(InputArguments input, string refreshToken) + { + OAuth2TokenResult result = await _giteaAuth.GetOAuthTokenViaRefresh(input.GetRemoteUri(), refreshToken); + return new OAuthCredential(result); + } + + protected override void ReleaseManagedResources() + { + _giteaAuth.Dispose(); + base.ReleaseManagedResources(); + } + + private string GetRefreshTokenServiceName(InputArguments input) + { + var builder = new UriBuilder(GetServiceName(input)); + builder.Host = "oauth-refresh-token." + builder.Host; + return builder.Uri.ToString(); + } + + public override Task EraseCredentialAsync(InputArguments input) + { + // delete any refresh token too + Context.CredentialStore.Remove(GetRefreshTokenServiceName(input), "oauth2"); + return base.EraseCredentialAsync(input); + } + } +} diff --git a/src/shared/Gitea/GiteaOAuth2Client.cs b/src/shared/Gitea/GiteaOAuth2Client.cs new file mode 100644 index 000000000..f78c5bea0 --- /dev/null +++ b/src/shared/Gitea/GiteaOAuth2Client.cs @@ -0,0 +1,63 @@ +using System; +using System.Net.Http; +using GitCredentialManager; +using GitCredentialManager.Authentication.OAuth; + +namespace Gitea +{ + public class GiteaOAuth2Client : OAuth2Client + { + public GiteaOAuth2Client(HttpClient httpClient, ISettings settings, Uri baseUri) + : base(httpClient, CreateEndpoints(baseUri), + GetClientId(settings), GetRedirectUri(settings), GetClientSecret(settings)) + { } + + private static OAuth2ServerEndpoints CreateEndpoints(Uri baseUri) + { + Uri authEndpoint = new Uri(baseUri, GiteaConstants.OAuthAuthorizationEndpointRelativeUri); + Uri tokenEndpoint = new Uri(baseUri, GiteaConstants.OAuthTokenEndpointRelativeUri); + + return new OAuth2ServerEndpoints(authEndpoint, tokenEndpoint); + } + + private static Uri GetRedirectUri(ISettings settings) + { + // Check for developer override value + if (settings.TryGetSetting( + GiteaConstants.EnvironmentVariables.DevOAuthRedirectUri, + Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.DevOAuthRedirectUri, + out string redirectUriStr) && Uri.TryCreate(redirectUriStr, UriKind.Absolute, out Uri redirectUri)) + { + return redirectUri; + } + + return GiteaConstants.OAuthRedirectUri; + } + + internal static string GetClientId(ISettings settings) + { + // Check for developer override value + if (settings.TryGetSetting( + GiteaConstants.EnvironmentVariables.DevOAuthClientId, + Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.DevOAuthClientId, + out string clientId)) + { + return clientId; + } + return null; + } + + private static string GetClientSecret(ISettings settings) + { + // Check for developer override value + if (settings.TryGetSetting( + GiteaConstants.EnvironmentVariables.DevOAuthClientSecret, + Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.DevOAuthClientSecret, + out string clientSecret)) + { + return clientSecret; + } + return null; + } + } +} diff --git a/src/shared/Gitea/InternalsVisibleTo.cs b/src/shared/Gitea/InternalsVisibleTo.cs new file mode 100644 index 000000000..07f3cb965 --- /dev/null +++ b/src/shared/Gitea/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Gitea.Tests")]