Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 0eac6e9

Browse files
committedOct 7, 2022
Attempt at Gitea support
1 parent 9b3bf45 commit 0eac6e9

File tree

11 files changed

+619
-1
lines changed

11 files changed

+619
-1
lines changed
 

‎Git-Credential-Manager.sln‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ EndProject
7070
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}"
7171
EndProject
7272
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}"
73+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gitea", "src\shared\Gitea\Gitea.csproj", "{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}"
7374
EndProject
7475
Global
7576
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -415,6 +416,23 @@ Global
415416
{570897DC-A85C-4598-B793-9A00CF710119}.MacRelease|Any CPU.Build.0 = Release|Any CPU
416417
{570897DC-A85C-4598-B793-9A00CF710119}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
417418
{570897DC-A85C-4598-B793-9A00CF710119}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
419+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
420+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
421+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU
422+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacDebug|Any CPU.Build.0 = Debug|Any CPU
423+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
424+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.Release|Any CPU.Build.0 = Release|Any CPU
425+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU
426+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
427+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxDebug|Any CPU.ActiveCfg = Debug|Any CPU
428+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxDebug|Any CPU.Build.0 = Debug|Any CPU
429+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxRelease|Any CPU.ActiveCfg = Release|Any CPU
430+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.LinuxRelease|Any CPU.Build.0 = Release|Any CPU
431+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacRelease|Any CPU.ActiveCfg = Release|Any CPU
432+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.MacRelease|Any CPU.Build.0 = Release|Any CPU
433+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
434+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
435+
{FEF135C0-F03B-4E19-8AD7-20DFC4DC76E7} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
418436
{1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
419437
{1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.Debug|Any CPU.Build.0 = Debug|Any CPU
420438
{1AF9F7C5-FA2E-48F1-B216-4D5E9A27F393}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Compared to Git's [built-in credential helpers][git-tools-credential-storage]
1212
provides single-factor authentication support working on any HTTP-enabled Git
1313
repository, GCM provides multi-factor authentication support for
1414
[Azure DevOps][azure-devops], Azure DevOps Server (formerly Team Foundation
15-
Server), GitHub, Bitbucket, and GitLab.
15+
Server), GitHub, Bitbucket, GitLab and Gitea.
1616

1717
Git Credential Manager (GCM) replaces the .NET Framework-based
1818
[Git Credential Manager for Windows][gcm-for-windows] (GCM), and the Java-based
@@ -36,6 +36,7 @@ Multi-factor authentication support for Azure DevOps|✓|✓|✓
3636
Two-factor authentication support for GitHub|✓|✓|✓
3737
Two-factor authentication support for Bitbucket|✓|✓|✓
3838
Two-factor authentication support for GitLab|✓|✓|✓
39+
Two-factor authentication support for Gitea|✓|✓|✓
3940
Windows Integrated Authentication (NTLM/Kerberos) support|✓|_N/A_|_N/A_
4041
Basic HTTP authentication support|✓|✓|✓
4142
Proxy support|✓|✓|✓
@@ -283,6 +284,7 @@ See detailed information [here][gcm-http-proxy].
283284
- [Host provider specification][gcm-host-provider]
284285
- [Azure Repos OAuth tokens][gcm-azure-tokens]
285286
- [GitLab support][gcm-gitlab]
287+
- [Gitea support][gcm-gitea]
286288

287289
## Experimental Features
288290

@@ -322,6 +324,7 @@ When using GitHub logos, please be sure to follow the
322324
[gcm-for-mac-and-linux]: https://github.com/microsoft/Git-Credential-Manager-for-Mac-and-Linux
323325
[gcm-for-windows]: https://github.com/microsoft/Git-Credential-Manager-for-Windows
324326
[gcm-gitlab]: docs/gitlab.md
327+
[gcm-gitea]: docs/gitea.md
325328
[gcm-host-provider]: docs/hostprovider.md
326329
[gcm-http-proxy]: docs/netconfig.md#http-proxy
327330
[gcm-license]: LICENSE

‎docs/gitea.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Gitea support
2+
3+
GCM requires Gitea v1.18 or later.
4+
5+
## Config for a custom instance
6+
7+
1. In Settings / Applications, create an OAuth application, unchecking the *Confidential* option (if this option is missing, the version of Gitea is too old)
8+
1. Copy the application ID and configure
9+
`git config --global credential.https://gitea.example.com.GiteaDevClientId <APPLICATION_ID>`
10+
1. Copy the application secret and configure
11+
`git config --global credential.https://gitea.example.com.GiteaDevClientSecret
12+
<APPLICATION_SECRET>`
13+
1. Configure authentication modes to include 'browser'
14+
`git config --global credential.https://gitea.example.com.GiteaAuthModes browser`
15+
1. For good measure, configure
16+
`git config --global credential.https://gitea.example.com.provider gitea`.
17+
This may be necessary to recognise the domain as a Gitea instance.
18+
1. Verify the config is as expected
19+
`git config --global --get-urlmatch credential https://gitea.example.com`
20+
21+
## Clearing config
22+
23+
### Clearing config
24+
25+
```console
26+
git config --global --unset-all credential.https://gitea.example.com.GiteaDevClientId
27+
git config --global --unset-all credential.https://gitea.example.com.GiteaDevClientSecret
28+
git config --global --unset-all credential.https://gitea.example.com.provider
29+
```
30+
31+
## Config for popular instances
32+
33+
For convenience, here are the config commands for several popular GitLab
34+
instances, provided by community member [hickford](https://github.com/hickford/):
35+
36+
```console
37+
# https://codeberg.org/
38+
```
39+
40+
## Preferences
41+
42+
```console
43+
Select an authentication method for 'https://gitea.com/':
44+
1. Web browser (default)
45+
2. Personal access token
46+
3. Username/password
47+
option (enter for default):
48+
```
49+
50+
If you have a preferred authentication mode, you can specify
51+
[credential.gitLabAuthModes][config-gitlab-auth-modes]:
52+
53+
```console
54+
git config --global credential.giteaauthmodes browser
55+
```

‎src/shared/Git-Credential-Manager/Git-Credential-Manager.csproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
<ProjectReference Include="..\Atlassian.Bitbucket\Atlassian.Bitbucket.csproj" />
1919
<ProjectReference Include="..\GitHub\GitHub.csproj" />
2020
<ProjectReference Include="..\GitLab\GitLab.csproj" />
21+
<ProjectReference Include="..\Gitea\Gitea.csproj" />
2122
<ProjectReference Include="..\Microsoft.AzureRepos\Microsoft.AzureRepos.csproj" />
2223
<ProjectReference Include="..\Core\Core.csproj" />
2324
</ItemGroup>

‎src/shared/Git-Credential-Manager/Program.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using Atlassian.Bitbucket;
33
using GitHub;
44
using GitLab;
5+
using Gitea;
56
using Microsoft.AzureRepos;
67
using GitCredentialManager.Authentication;
78

@@ -37,6 +38,7 @@ public static void Main(string[] args)
3738
app.RegisterProvider(new BitbucketHostProvider(context), HostProviderPriority.Normal);
3839
app.RegisterProvider(new GitHubHostProvider(context), HostProviderPriority.Normal);
3940
app.RegisterProvider(new GitLabHostProvider(context), HostProviderPriority.Normal);
41+
app.RegisterProvider(new GiteaHostProvider(context), HostProviderPriority.Normal);
4042
app.RegisterProvider(new GenericHostProvider(context), HostProviderPriority.Low);
4143

4244
int exitCode = app.RunAsync(args)

‎src/shared/Gitea/Gitea.csproj‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>netstandard2.0</TargetFrameworks>
5+
<TargetFrameworks Condition="'$(OSPlatform)'=='windows'">netstandard2.0;net472</TargetFrameworks>
6+
<AssemblyName>Gitea</AssemblyName>
7+
<RootNamespace>Gitea</RootNamespace>
8+
<IsTestProject>false</IsTestProject>
9+
<LangVersion>latest</LangVersion>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<ProjectReference Include="..\Core\Core.csproj" />
14+
</ItemGroup>
15+
16+
<ItemGroup Condition="'$(TargetFramework)' == 'net472'">
17+
<Reference Include="System.Net.Http" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
using System;
2+
using System.Threading.Tasks;
3+
using System.Collections.Generic;
4+
using System.Net.Http;
5+
using System.Text;
6+
using System.Threading;
7+
using GitCredentialManager;
8+
using GitCredentialManager.Authentication;
9+
using GitCredentialManager.Authentication.OAuth;
10+
11+
namespace Gitea
12+
{
13+
public interface IGiteaAuthentication : IDisposable
14+
{
15+
AuthenticationPromptResult GetAuthentication(Uri targetUri, string userName, AuthenticationModes modes);
16+
17+
Task<OAuth2TokenResult> GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable<string> scopes);
18+
19+
Task<OAuth2TokenResult> GetOAuthTokenViaRefresh(Uri targetUri, string refreshToken);
20+
}
21+
22+
public class AuthenticationPromptResult
23+
{
24+
public AuthenticationPromptResult(AuthenticationModes mode)
25+
{
26+
AuthenticationMode = mode;
27+
}
28+
29+
public AuthenticationPromptResult(AuthenticationModes mode, ICredential credential)
30+
: this(mode)
31+
{
32+
Credential = credential;
33+
}
34+
35+
public AuthenticationModes AuthenticationMode { get; }
36+
37+
public ICredential Credential { get; set; }
38+
}
39+
40+
[Flags]
41+
public enum AuthenticationModes
42+
{
43+
None = 0,
44+
Basic = 1,
45+
Browser = 1 << 1,
46+
Pat = 1 << 2,
47+
48+
All = Basic | Browser | Pat
49+
}
50+
51+
public class GiteaAuthentication : AuthenticationBase, IGiteaAuthentication
52+
{
53+
public GiteaAuthentication(ICommandContext context)
54+
: base(context) { }
55+
56+
public AuthenticationPromptResult GetAuthentication(Uri targetUri, string userName, AuthenticationModes modes)
57+
{
58+
// If we don't have a desktop session/GUI then we cannot offer browser
59+
if (!Context.SessionManager.IsDesktopSession)
60+
{
61+
modes = modes & ~AuthenticationModes.Browser;
62+
}
63+
64+
// We need at least one mode!
65+
if (modes == AuthenticationModes.None)
66+
{
67+
throw new ArgumentException(@$"Must specify at least one {nameof(AuthenticationModes)}", nameof(modes));
68+
}
69+
70+
{
71+
switch (modes)
72+
{
73+
case AuthenticationModes.Basic:
74+
ThrowIfUserInteractionDisabled();
75+
ThrowIfTerminalPromptsDisabled();
76+
Context.Terminal.WriteLine("Enter Gitea credentials for '{0}'...", targetUri);
77+
78+
if (string.IsNullOrWhiteSpace(userName))
79+
{
80+
userName = Context.Terminal.Prompt("Username");
81+
}
82+
else
83+
{
84+
Context.Terminal.WriteLine("Username: {0}", userName);
85+
}
86+
87+
string password = Context.Terminal.PromptSecret("Password");
88+
return new AuthenticationPromptResult(AuthenticationModes.Basic, new GitCredential(userName, password));
89+
90+
case AuthenticationModes.Pat:
91+
ThrowIfUserInteractionDisabled();
92+
ThrowIfTerminalPromptsDisabled();
93+
Context.Terminal.WriteLine("Enter Gitea credentials for '{0}'...", targetUri);
94+
95+
if (string.IsNullOrWhiteSpace(userName))
96+
{
97+
userName = Context.Terminal.Prompt("Username");
98+
}
99+
else
100+
{
101+
Context.Terminal.WriteLine("Username: {0}", userName);
102+
}
103+
104+
string token = Context.Terminal.PromptSecret("Personal access token");
105+
return new AuthenticationPromptResult(AuthenticationModes.Pat, new GitCredential(userName, token));
106+
107+
case AuthenticationModes.Browser:
108+
return new AuthenticationPromptResult(AuthenticationModes.Browser);
109+
110+
case AuthenticationModes.None:
111+
throw new ArgumentOutOfRangeException(nameof(modes), @$"At least one {nameof(AuthenticationModes)} must be supplied");
112+
113+
default:
114+
ThrowIfUserInteractionDisabled();
115+
ThrowIfTerminalPromptsDisabled();
116+
var menuTitle = $"Select an authentication method for '{targetUri}'";
117+
var menu = new TerminalMenu(Context.Terminal, menuTitle);
118+
119+
TerminalMenuItem browserItem = null;
120+
TerminalMenuItem basicItem = null;
121+
TerminalMenuItem patItem = null;
122+
123+
if ((modes & AuthenticationModes.Browser) != 0) browserItem = menu.Add("Web browser");
124+
if ((modes & AuthenticationModes.Pat) != 0) patItem = menu.Add("Personal access token");
125+
if ((modes & AuthenticationModes.Basic) != 0) basicItem = menu.Add("Username/password");
126+
127+
// Default to the 'first' choice in the menu
128+
TerminalMenuItem choice = menu.Show(0);
129+
130+
if (choice == browserItem) goto case AuthenticationModes.Browser;
131+
if (choice == basicItem) goto case AuthenticationModes.Basic;
132+
if (choice == patItem) goto case AuthenticationModes.Pat;
133+
134+
throw new Exception();
135+
}
136+
}
137+
}
138+
139+
public async Task<OAuth2TokenResult> GetOAuthTokenViaBrowserAsync(Uri targetUri, IEnumerable<string> scopes)
140+
{
141+
ThrowIfUserInteractionDisabled();
142+
143+
var oauthClient = new GiteaOAuth2Client(HttpClient, Context.Settings, targetUri);
144+
145+
// We require a desktop session to launch the user's default web browser
146+
if (!Context.SessionManager.IsDesktopSession)
147+
{
148+
throw new InvalidOperationException("Browser authentication requires a desktop session");
149+
}
150+
151+
var browserOptions = new OAuth2WebBrowserOptions { };
152+
var browser = new OAuth2SystemWebBrowser(Context.Environment, browserOptions);
153+
154+
// Write message to the terminal (if any is attached) for some feedback that we're waiting for a web response
155+
Context.Terminal.WriteLine("info: please complete authentication in your browser...");
156+
157+
OAuth2AuthorizationCodeResult authCodeResult =
158+
await oauthClient.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None);
159+
160+
return await oauthClient.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None);
161+
}
162+
163+
public async Task<OAuth2TokenResult> GetOAuthTokenViaRefresh(Uri targetUri, string refreshToken)
164+
{
165+
var oauthClient = new GiteaOAuth2Client(HttpClient, Context.Settings, targetUri);
166+
return await oauthClient.GetTokenByRefreshTokenAsync(refreshToken, CancellationToken.None);
167+
}
168+
169+
private HttpClient _httpClient;
170+
private HttpClient HttpClient => _httpClient ?? (_httpClient = Context.HttpClientFactory.CreateClient());
171+
172+
public void Dispose()
173+
{
174+
_httpClient?.Dispose();
175+
}
176+
}
177+
}

‎src/shared/Gitea/GiteaConstants.cs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System;
2+
3+
namespace Gitea
4+
{
5+
public static class GiteaConstants
6+
{
7+
public static readonly Uri OAuthRedirectUri = new Uri("http://127.0.0.1/");
8+
// https://docs.gitea.io/en-us/oauth2-provider/
9+
public static readonly Uri OAuthAuthorizationEndpointRelativeUri = new Uri("/login/oauth/authorize", UriKind.Relative);
10+
public static readonly Uri OAuthTokenEndpointRelativeUri = new Uri("/login/oauth/access_token", UriKind.Relative);
11+
12+
public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.All;
13+
14+
public static class EnvironmentVariables
15+
{
16+
public const string DevOAuthClientId = "GCM_DEV_GITEA_CLIENTID";
17+
public const string DevOAuthClientSecret = "GCM_DEV_GITEA_CLIENTSECRET";
18+
public const string DevOAuthRedirectUri = "GCM_DEV_GITEA_REDIRECTURI";
19+
public const string AuthenticationModes = "GCM_GITEA_AUTHMODES";
20+
public const string AuthenticationHelper = "GCM_GITEA_HELPER";
21+
}
22+
23+
public static class GitConfiguration
24+
{
25+
public static class Credential
26+
{
27+
public const string AuthenticationModes = "giteaAuthModes";
28+
public const string DevOAuthClientId = "giteaDevClientId";
29+
public const string DevOAuthClientSecret = "giteaDevClientSecret";
30+
public const string DevOAuthRedirectUri = "giteaDevRedirectUri";
31+
public const string AuthenticationHelper = "giteaHelper";
32+
}
33+
}
34+
35+
public static class HelpUrls
36+
{
37+
public const string Gitea = "https://github.com/GitCredentialManager/git-credential-manager/blob/main/docs/gitea.md";
38+
}
39+
}
40+
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
using System;
2+
using System.Linq;
3+
using System.Net.Http;
4+
using System.Threading.Tasks;
5+
using GitCredentialManager;
6+
using GitCredentialManager.Authentication.OAuth;
7+
using System.Net.Http.Headers;
8+
9+
namespace Gitea
10+
{
11+
public class GiteaHostProvider : HostProvider
12+
{
13+
private static readonly string[] GiteaOAuthScopes =
14+
{
15+
};
16+
17+
private readonly IGiteaAuthentication _giteaAuth;
18+
19+
public GiteaHostProvider(ICommandContext context)
20+
: this(context, new GiteaAuthentication(context)) { }
21+
22+
public GiteaHostProvider(ICommandContext context, IGiteaAuthentication giteaAuth)
23+
: base(context)
24+
{
25+
EnsureArgument.NotNull(giteaAuth, nameof(giteaAuth));
26+
27+
_giteaAuth = giteaAuth;
28+
}
29+
30+
public override string Id => "gitea";
31+
32+
public override string Name => "Gitea";
33+
34+
public override bool IsSupported(InputArguments input)
35+
{
36+
if (input is null)
37+
{
38+
return false;
39+
}
40+
41+
// We do not support unencrypted HTTP communications to Gitea,
42+
// but we report `true` here for HTTP so that we can show a helpful
43+
// error message for the user in `CreateCredentialAsync`.
44+
if (!StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") &&
45+
!StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https"))
46+
{
47+
return false;
48+
}
49+
50+
// Split port number and hostname from host input argument
51+
if (!input.TryGetHostAndPort(out string hostName, out _))
52+
{
53+
return false;
54+
}
55+
56+
string[] domains = hostName.Split(new char[] { '.' });
57+
58+
// Gitea[.subdomain].domain.tld
59+
if (domains.Length >= 3 &&
60+
StringComparer.OrdinalIgnoreCase.Equals(domains[0], "gitea"))
61+
{
62+
return true;
63+
}
64+
65+
return false;
66+
}
67+
68+
public override bool IsSupported(HttpResponseMessage response) =>
69+
response?.Headers.Any(pair => pair.Key == "Set-Cookie" && pair.Value.Any(x => x.Contains("i_like_gitea="))) ?? false;
70+
71+
public override async Task<ICredential> GenerateCredentialAsync(InputArguments input)
72+
{
73+
ThrowIfDisposed();
74+
75+
Uri remoteUri = input.GetRemoteUri();
76+
77+
AuthenticationModes authModes = GetSupportedAuthenticationModes(remoteUri);
78+
79+
AuthenticationPromptResult promptResult = _giteaAuth.GetAuthentication(remoteUri, input.UserName, authModes);
80+
81+
switch (promptResult.AuthenticationMode)
82+
{
83+
case AuthenticationModes.Basic:
84+
case AuthenticationModes.Pat:
85+
return promptResult.Credential;
86+
87+
case AuthenticationModes.Browser:
88+
return await GenerateOAuthCredentialAsync(input);
89+
90+
default:
91+
throw new ArgumentOutOfRangeException(nameof(promptResult));
92+
}
93+
}
94+
95+
internal AuthenticationModes GetSupportedAuthenticationModes(Uri targetUri)
96+
{
97+
// Check for an explicit override for supported authentication modes
98+
if (Context.Settings.TryGetSetting(
99+
GiteaConstants.EnvironmentVariables.AuthenticationModes,
100+
Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.AuthenticationModes,
101+
out string authModesStr))
102+
{
103+
if (Enum.TryParse(authModesStr, true, out AuthenticationModes authModes) && authModes != AuthenticationModes.None)
104+
{
105+
Context.Trace.WriteLine($"Supported authentication modes override present: {authModes}");
106+
return authModes;
107+
}
108+
else
109+
{
110+
Context.Trace.WriteLine($"Invalid value for supported authentication modes override setting: '{authModesStr}'");
111+
}
112+
}
113+
114+
// Try to detect what auth modes are available for this non-Gitea.com host.
115+
// Assume that PATs are always available to give at least one option to users!
116+
var modes = AuthenticationModes.Pat;
117+
118+
// If there is a configured OAuth client ID
119+
// then assume OAuth is possible.
120+
string oauthClientId = GiteaOAuth2Client.GetClientId(Context.Settings);
121+
if (oauthClientId != null) {
122+
modes |= AuthenticationModes.Browser;
123+
} else {
124+
// Tell the user that they may wish to configure OAuth for this Gitea instance
125+
Context.Streams.Error.WriteLine(
126+
$"warning: missing OAuth configuration for {targetUri.Host} - see {GiteaConstants.HelpUrls.Gitea} for more information");
127+
}
128+
129+
// assume password auth is always available.
130+
bool supportsBasic = true;
131+
if (supportsBasic)
132+
{
133+
modes |= AuthenticationModes.Basic;
134+
}
135+
136+
return modes;
137+
}
138+
139+
// <remarks>Stores OAuth tokens as a side effect</remarks>
140+
public override async Task<ICredential> GetCredentialAsync(InputArguments input)
141+
{
142+
string service = GetServiceName(input);
143+
ICredential credential = Context.CredentialStore.Get(service, input.UserName);
144+
if (credential?.Account == "oauth2" && IsOAuthTokenExpired(input.GetRemoteUri(), credential.Password))
145+
{
146+
Context.Trace.WriteLine("Removing (possibly) expired OAuth access token...");
147+
Context.CredentialStore.Remove(service, credential.Account);
148+
credential = null;
149+
}
150+
151+
if (credential != null)
152+
{
153+
return credential;
154+
}
155+
156+
string refreshService = GetRefreshTokenServiceName(input);
157+
string refreshToken = Context.CredentialStore.Get(refreshService, input.UserName)?.Password;
158+
if (refreshToken != null)
159+
{
160+
Context.Trace.WriteLine("Refreshing OAuth token...");
161+
try
162+
{
163+
credential = await RefreshOAuthCredentialAsync(input, refreshToken);
164+
}
165+
catch (Exception e)
166+
{
167+
Context.Terminal.WriteLine($"OAuth token refresh failed: {e.Message}");
168+
}
169+
}
170+
171+
credential ??= await GenerateCredentialAsync(input);
172+
173+
if (credential is OAuthCredential oAuthCredential)
174+
{
175+
Context.Trace.WriteLine("Pre-emptively storing OAuth access and refresh tokens...");
176+
// freshly-generated OAuth credential
177+
// store credential, since we know it to be valid (whereas Git will only store credential if git push succeeds)
178+
Context.CredentialStore.AddOrUpdate(service, oAuthCredential.Account, oAuthCredential.AccessToken);
179+
// store refresh token under a separate service
180+
Context.CredentialStore.AddOrUpdate(refreshService, oAuthCredential.Account, oAuthCredential.RefreshToken);
181+
}
182+
return credential;
183+
}
184+
185+
private bool IsOAuthTokenExpired(Uri baseUri, string accessToken)
186+
{
187+
return true;
188+
}
189+
190+
internal class OAuthCredential : ICredential
191+
{
192+
public OAuthCredential(OAuth2TokenResult oAuth2TokenResult)
193+
{
194+
AccessToken = oAuth2TokenResult.AccessToken;
195+
RefreshToken = oAuth2TokenResult.RefreshToken;
196+
}
197+
198+
public string Account => "oauth2";
199+
public string AccessToken { get; }
200+
public string RefreshToken { get; }
201+
string ICredential.Password => AccessToken;
202+
}
203+
204+
private async Task<OAuthCredential> GenerateOAuthCredentialAsync(InputArguments input)
205+
{
206+
OAuth2TokenResult result = await _giteaAuth.GetOAuthTokenViaBrowserAsync(input.GetRemoteUri(), GiteaOAuthScopes);
207+
return new OAuthCredential(result);
208+
}
209+
210+
private async Task<OAuthCredential> RefreshOAuthCredentialAsync(InputArguments input, string refreshToken)
211+
{
212+
OAuth2TokenResult result = await _giteaAuth.GetOAuthTokenViaRefresh(input.GetRemoteUri(), refreshToken);
213+
return new OAuthCredential(result);
214+
}
215+
216+
protected override void ReleaseManagedResources()
217+
{
218+
_giteaAuth.Dispose();
219+
base.ReleaseManagedResources();
220+
}
221+
222+
private string GetRefreshTokenServiceName(InputArguments input)
223+
{
224+
var builder = new UriBuilder(GetServiceName(input));
225+
builder.Host = "oauth-refresh-token." + builder.Host;
226+
return builder.Uri.ToString();
227+
}
228+
229+
public override Task EraseCredentialAsync(InputArguments input)
230+
{
231+
// delete any refresh token too
232+
Context.CredentialStore.Remove(GetRefreshTokenServiceName(input), "oauth2");
233+
return base.EraseCredentialAsync(input);
234+
}
235+
}
236+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using System;
2+
using System.Net.Http;
3+
using GitCredentialManager;
4+
using GitCredentialManager.Authentication.OAuth;
5+
6+
namespace Gitea
7+
{
8+
public class GiteaOAuth2Client : OAuth2Client
9+
{
10+
public GiteaOAuth2Client(HttpClient httpClient, ISettings settings, Uri baseUri)
11+
: base(httpClient, CreateEndpoints(baseUri),
12+
GetClientId(settings), GetRedirectUri(settings), GetClientSecret(settings))
13+
{ }
14+
15+
private static OAuth2ServerEndpoints CreateEndpoints(Uri baseUri)
16+
{
17+
Uri authEndpoint = new Uri(baseUri, GiteaConstants.OAuthAuthorizationEndpointRelativeUri);
18+
Uri tokenEndpoint = new Uri(baseUri, GiteaConstants.OAuthTokenEndpointRelativeUri);
19+
20+
return new OAuth2ServerEndpoints(authEndpoint, tokenEndpoint);
21+
}
22+
23+
private static Uri GetRedirectUri(ISettings settings)
24+
{
25+
// Check for developer override value
26+
if (settings.TryGetSetting(
27+
GiteaConstants.EnvironmentVariables.DevOAuthRedirectUri,
28+
Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.DevOAuthRedirectUri,
29+
out string redirectUriStr) && Uri.TryCreate(redirectUriStr, UriKind.Absolute, out Uri redirectUri))
30+
{
31+
return redirectUri;
32+
}
33+
34+
return GiteaConstants.OAuthRedirectUri;
35+
}
36+
37+
internal static string GetClientId(ISettings settings)
38+
{
39+
// Check for developer override value
40+
if (settings.TryGetSetting(
41+
GiteaConstants.EnvironmentVariables.DevOAuthClientId,
42+
Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.DevOAuthClientId,
43+
out string clientId))
44+
{
45+
return clientId;
46+
}
47+
return null;
48+
}
49+
50+
private static string GetClientSecret(ISettings settings)
51+
{
52+
// Check for developer override value
53+
if (settings.TryGetSetting(
54+
GiteaConstants.EnvironmentVariables.DevOAuthClientSecret,
55+
Constants.GitConfiguration.Credential.SectionName, GiteaConstants.GitConfiguration.Credential.DevOAuthClientSecret,
56+
out string clientSecret))
57+
{
58+
return clientSecret;
59+
}
60+
return null;
61+
}
62+
}
63+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using System.Runtime.CompilerServices;
2+
3+
[assembly: InternalsVisibleTo("Gitea.Tests")]

0 commit comments

Comments
 (0)
Please sign in to comment.