Skip to content

Gitea support #879

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Git-Credential-Manager.sln
Original file line number Diff line number Diff line change
@@ -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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -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
33 changes: 33 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions docs/gitea.md
Original file line number Diff line number Diff line change
@@ -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 <APPLICATION_ID>`
1. Copy the application secret and configure
`git config --global credential.https://gitea.example.com.GiteaDevClientSecret
<APPLICATION_SECRET>`
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
```
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@
<ProjectReference Include="..\Atlassian.Bitbucket\Atlassian.Bitbucket.csproj" />
<ProjectReference Include="..\GitHub\GitHub.csproj" />
<ProjectReference Include="..\GitLab\GitLab.csproj" />
<ProjectReference Include="..\Gitea\Gitea.csproj" />
<ProjectReference Include="..\Microsoft.AzureRepos\Microsoft.AzureRepos.csproj" />
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>
2 changes: 2 additions & 0 deletions src/shared/Git-Credential-Manager/Program.cs
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 20 additions & 0 deletions src/shared/Gitea/Gitea.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>netstandard2.0</TargetFrameworks>
<TargetFrameworks Condition="'$(OSPlatform)'=='windows'">netstandard2.0;net472</TargetFrameworks>
<AssemblyName>Gitea</AssemblyName>
<RootNamespace>Gitea</RootNamespace>
<IsTestProject>false</IsTestProject>
<LangVersion>latest</LangVersion>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
<Reference Include="System.Net.Http" />
</ItemGroup>

</Project>
177 changes: 177 additions & 0 deletions src/shared/Gitea/GiteaAuthentication.cs
Original file line number Diff line number Diff line change
@@ -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<OAuth2TokenResult> GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable<string> scopes);

Task<OAuth2TokenResult> 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<OAuth2TokenResult> GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable<string> 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<OAuth2TokenResult> 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();
}
}
}
40 changes: 40 additions & 0 deletions src/shared/Gitea/GiteaConstants.cs
Original file line number Diff line number Diff line change
@@ -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";
}
}
}
236 changes: 236 additions & 0 deletions src/shared/Gitea/GiteaHostProvider.cs
Original file line number Diff line number Diff line change
@@ -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<ICredential> 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;
}

// <remarks>Stores OAuth tokens as a side effect</remarks>
public override async Task<ICredential> 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<OAuthCredential> GenerateOAuthCredentialAsync(InputArguments input)
{
OAuth2TokenResult result = await _giteaAuth.GetOAuthTokenViaBrowserAsync(input.GetRemoteUri(), GiteaOAuthScopes);
return new OAuthCredential(result);
}

private async Task<OAuthCredential> 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);
}
}
}
63 changes: 63 additions & 0 deletions src/shared/Gitea/GiteaOAuth2Client.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
3 changes: 3 additions & 0 deletions src/shared/Gitea/InternalsVisibleTo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Gitea.Tests")]