Skip to content

Commit 66b94e4

Browse files
authored
Add OAuth support to generic host provider (#1062)
Add ability to provide OAuth-based authentication for generic hosts by way of simple Git configuration. When a remote URL does not match any known host provider plugin, the generic provider will now first check for OAuth configuration in the Git config or environment variables. If such config is available then we try and perform OAuth authentication. Support for device code flow is optional, and refresh tokens will be used if the service supports and returns them. Users can make use of existing Git config `include` to easily organise and share custom OAuth configurations.
2 parents 506afa6 + d8aa30b commit 66b94e4

33 files changed

+1674
-21
lines changed

docs/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The following are links to GCM user support documentation:
1212
- [Host provider specification][gcm-host-provider]
1313
- [Azure Repos OAuth tokens][gcm-azure-tokens]
1414
- [GitLab support][gcm-gitlab]
15+
- [Generic OAuth support][gcm-oauth]
1516

1617
[gcm-azure-tokens]: azrepos-users-and-tokens.md
1718
[gcm-config]: configuration.md
@@ -23,4 +24,5 @@ The following are links to GCM user support documentation:
2324
[gcm-gitlab]: gitlab.md
2425
[gcm-host-provider]: hostprovider.md
2526
[gcm-net-config]: netconfig.md
26-
[gcm-usage]: usage.md
27+
[gcm-oauth]: generic-oauth.md
28+
[gcm-usage]: usage.md

docs/generic-oauth.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Generic Host Provider OAuth
2+
3+
Many Git hosts use the popular standard OAuth2 or OpenID Connect (OIDC)
4+
authentication mechanisms to secure repositories they host.
5+
Git Credential Manager supports any generic OAuth2-based Git host by simply
6+
setting some configuration.
7+
8+
## Registering an OAuth application
9+
10+
In order to use GCM with a Git host that supports OAuth you must first have
11+
registered an OAuth application with your host. The instructions on how to do
12+
this can be found with your Git host provider's documentation.
13+
14+
When registering a new application, you should make sure to set an HTTP-based
15+
redirect URL that points to `localhost`; for example:
16+
17+
```text
18+
http://localhost
19+
http://localhost:<port>
20+
http://127.0.0.1
21+
http://127.0.0.1:<port>
22+
```
23+
24+
Note that you cannot use an HTTPS redirect URL. GCM does not require a specific
25+
port number be used; if your Git host requires you to specify a port number in
26+
the redirect URL then GCM will use that. Otherwise an available port will be
27+
selected at the point authentication starts.
28+
29+
You must ensure that all scopes required to read and write to Git repositories
30+
have been granted for the application or else credentials that are generated
31+
will cause errors when pushing or fetching using Git.
32+
33+
As part of the registration process you should also be given a Client ID and,
34+
optionally, a Client Secret. You will need both of these to configure GCM.
35+
36+
## Configure GCM
37+
38+
In order to configure GCM to use OAuth with your Git host you need to set the
39+
following values in your Git configuration:
40+
41+
- Client ID
42+
- Client Secret (optional)
43+
- Redirect URL
44+
- Scopes (optional)
45+
- OAuth Endpoints
46+
- Authorization Endpoint
47+
- Token Endpoint
48+
- Device Code Authorization Endpoint (optional)
49+
50+
OAuth endpoints can be found by consulting your Git host's OAuth app development
51+
documentation. The URLs can be either absolute or relative to the host name;
52+
for example: `https://example.com/oauth/authorize` or `/oauth/authorize`.
53+
54+
In order to set these values, you can run the following commands, where `<HOST>`
55+
is the hostname of your Git host:
56+
57+
```shell
58+
git config --global credential.<HOST>.oauthClientId <ClientID>
59+
git config --global credential.<HOST>.oauthClientSecret <ClientSecret>
60+
git config --global credential.<HOST>.oauthRedirectUri <RedirectURL>
61+
git config --global credential.<HOST>.oauthAuthorizeEndpoint <AuthEndpoint>
62+
git config --global credential.<HOST>.oauthTokenEndpoint <TokenEndpoint>
63+
git config --global credential.<HOST>.oauthScopes <Scopes>
64+
git config --global credential.<HOST>.oauthDeviceEndpoint <DeviceEndpoint>
65+
```
66+
67+
**Example commands:**
68+
69+
- `git config --global credential.https://example.com.oauthClientId C33F2751FB76`
70+
71+
- `git config --global credential.https://example.com.oauthScopes "code:write profile:read"`
72+
73+
**Example Git configuration**
74+
75+
```ini
76+
[credential "https://example.com"]
77+
oauthClientId = 9d886e36-5771-4f2b-8c8b-420c68ad5baa
78+
oauthClientSecret = 4BC5BD4704EAE28FD832
79+
oauthRedirectUri = "http://127.0.0.1"
80+
oauthAuthorizeEndpoint = "/login/oauth/authorize"
81+
oauthTokenEndpoint = "/login/oauth/token"
82+
oauthDeviceEndpoint = "/login/oauth/device"
83+
oauthScopes = "code:write profile:read"
84+
oauthDefaultUserName = "OAUTH"
85+
oauthUseClientAuthHeader = false
86+
```
87+
88+
### Additional configuration
89+
90+
Depending on the specific implementation of OAuth with your Git host you may
91+
also need to specify additional behavior.
92+
93+
#### Token user name
94+
95+
If your Git host requires that you specify a username to use with OAuth tokens
96+
you can either include the username in the Git remote URL, or specify a default
97+
option via Git configuration.
98+
99+
Example Git remote with username: `https://[email protected]/repo.git`.
100+
In order to use special characters you need to URL encode the values; for
101+
example `@` becomes `%40`.
102+
103+
By default GCM uses the value `OAUTH-USER` unless specified in the remote URL,
104+
or overriden using the `credential.<HOST>.oauthDefaultUserName` configuration.
105+
106+
#### Include client authentication in headers
107+
108+
If your Git host's OAuth implementation has specific requirements about whether
109+
the client ID and secret should or should not be included in an `Authorization`
110+
header during OAuth requests, you can control this using the following setting:
111+
112+
```shell
113+
git config --global credential.<HOST>.oauthUseClientAuthHeader <true|false>
114+
```
115+
116+
The default behavior is to include these values; i.e., `true`.

src/shared/Core.Tests/GenericHostProviderTests.cs

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Net.Http;
34
using System.Threading.Tasks;
45
using GitCredentialManager.Authentication;
6+
using GitCredentialManager.Authentication.OAuth;
57
using GitCredentialManager.Tests.Objects;
68
using Moq;
79
using Xunit;
@@ -87,8 +89,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return
8789
.ReturnsAsync(basicCredential)
8890
.Verifiable();
8991
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
92+
var oauthMock = new Mock<IOAuthAuthentication>();
9093

91-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
94+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
9295

9396
ICredential credential = await provider.GenerateCredentialAsync(input);
9497

@@ -121,8 +124,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic
121124
.ReturnsAsync(basicCredential)
122125
.Verifiable();
123126
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
127+
var oauthMock = new Mock<IOAuthAuthentication>();
124128

125-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
129+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
126130

127131
ICredential credential = await provider.GenerateCredentialAsync(input);
128132

@@ -152,8 +156,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu
152156
.ReturnsAsync(basicCredential)
153157
.Verifiable();
154158
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
159+
var oauthMock = new Mock<IOAuthAuthentication>();
155160

156-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
161+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
157162

158163
ICredential credential = await provider.GenerateCredentialAsync(input);
159164

@@ -182,6 +187,90 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotSupported_Retu
182187
await TestCreateCredentialAsync_ReturnsBasicCredential(wiaSupported: false);
183188
}
184189

190+
[Fact]
191+
public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAuthConfig_UsesOAuth()
192+
{
193+
var input = new InputArguments(new Dictionary<string, string>
194+
{
195+
["protocol"] = "https",
196+
["host"] = "git.example.com",
197+
["path"] = "foo"
198+
});
199+
200+
const string testUserName = "TEST_OAUTH_USER";
201+
const string testAcessToken = "OAUTH_TOKEN";
202+
const string testRefreshToken = "OAUTH_REFRESH_TOKEN";
203+
const string testResource = "https://git.example.com/foo";
204+
const string expectedRefreshTokenService = "https://refresh_token.git.example.com/foo";
205+
206+
var authMode = OAuthAuthenticationModes.Browser;
207+
string[] scopes = { "code:write", "code:read" };
208+
string clientId = "3eadfc62-9e91-45d3-8c60-20ccd6d0c7cf";
209+
string clientSecret = "C1DA8B93CCB5F5B93DA";
210+
string redirectUri = "http://localhost";
211+
string authzEndpoint = "/oauth/authorize";
212+
string tokenEndpoint = "/oauth/token";
213+
string deviceEndpoint = "/oauth/device";
214+
215+
string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}";
216+
217+
var context = new TestCommandContext
218+
{
219+
Git =
220+
{
221+
Configuration =
222+
{
223+
Global =
224+
{
225+
[GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { clientId },
226+
[GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { clientSecret },
227+
[GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { redirectUri },
228+
[GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', scopes) },
229+
[GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { authzEndpoint },
230+
[GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { tokenEndpoint },
231+
[GetKey(Constants.GitConfiguration.Credential.OAuthDeviceEndpoint)] = new[] { deviceEndpoint },
232+
[GetKey(Constants.GitConfiguration.Credential.OAuthDefaultUserName)] = new[] { testUserName },
233+
}
234+
}
235+
},
236+
Settings =
237+
{
238+
RemoteUri = new Uri(testResource)
239+
}
240+
};
241+
242+
var basicAuthMock = new Mock<IBasicAuthentication>();
243+
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
244+
var oauthMock = new Mock<IOAuthAuthentication>();
245+
oauthMock.Setup(x =>
246+
x.GetAuthenticationModeAsync(It.IsAny<string>(), It.IsAny<OAuthAuthenticationModes>()))
247+
.ReturnsAsync(authMode);
248+
oauthMock.Setup(x => x.GetTokenByBrowserAsync(It.IsAny<OAuth2Client>(), It.IsAny<string[]>()))
249+
.ReturnsAsync(new OAuth2TokenResult(testAcessToken, "access_token")
250+
{
251+
Scopes = scopes,
252+
RefreshToken = testRefreshToken
253+
});
254+
255+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
256+
257+
ICredential credential = await provider.GenerateCredentialAsync(input);
258+
259+
Assert.NotNull(credential);
260+
Assert.Equal(testUserName, credential.Account);
261+
Assert.Equal(testAcessToken, credential.Password);
262+
263+
Assert.True(context.CredentialStore.TryGet(expectedRefreshTokenService, null, out TestCredential refreshToken));
264+
Assert.Equal(testUserName, refreshToken.Account);
265+
Assert.Equal(testRefreshToken, refreshToken.Password);
266+
267+
oauthMock.Verify(x => x.GetAuthenticationModeAsync(testResource, OAuthAuthenticationModes.All), Times.Once);
268+
oauthMock.Verify(x => x.GetTokenByBrowserAsync(It.IsAny<OAuth2Client>(), scopes), Times.Once);
269+
oauthMock.Verify(x => x.GetTokenByDeviceCodeAsync(It.IsAny<OAuth2Client>(), scopes), Times.Never);
270+
wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny<Uri>()), Times.Never);
271+
basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
272+
}
273+
185274
#region Helpers
186275

187276
private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool wiaSupported)
@@ -199,8 +288,9 @@ private static async Task TestCreateCredentialAsync_ReturnsEmptyCredential(bool
199288
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
200289
wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny<Uri>()))
201290
.ReturnsAsync(wiaSupported);
291+
var oauthMock = new Mock<IOAuthAuthentication>();
202292

203-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
293+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
204294

205295
ICredential credential = await provider.GenerateCredentialAsync(input);
206296

@@ -230,8 +320,9 @@ private static async Task TestCreateCredentialAsync_ReturnsBasicCredential(bool
230320
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
231321
wiaAuthMock.Setup(x => x.GetIsSupportedAsync(It.IsAny<Uri>()))
232322
.ReturnsAsync(wiaSupported);
323+
var oauthMock = new Mock<IOAuthAuthentication>();
233324

234-
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
325+
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
235326

236327
ICredential credential = await provider.GenerateCredentialAsync(input);
237328

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using System;
2+
using GitCredentialManager.Tests.Objects;
3+
using Xunit;
4+
5+
namespace GitCredentialManager.Tests
6+
{
7+
public class GenericOAuthConfigTests
8+
{
9+
[Fact]
10+
public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue()
11+
{
12+
var remoteUri = new Uri("https://example.com");
13+
const string expectedClientId = "115845b0-77f8-4c06-a3dc-7d277381fad1";
14+
const string expectedClientSecret = "4D35385D9F24";
15+
const string expectedUserName = "TEST_USER";
16+
const string authzEndpoint = "/oauth/authorize";
17+
const string tokenEndpoint = "/oauth/token";
18+
const string deviceEndpoint = "/oauth/device";
19+
string[] expectedScopes = { "scope1", "scope2" };
20+
var expectedRedirectUri = new Uri("http://localhost:12345");
21+
var expectedAuthzEndpoint = new Uri(remoteUri, authzEndpoint);
22+
var expectedTokenEndpoint = new Uri(remoteUri, tokenEndpoint);
23+
var expectedDeviceEndpoint = new Uri(remoteUri, deviceEndpoint);
24+
25+
string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}";
26+
27+
var trace = new NullTrace();
28+
var settings = new TestSettings
29+
{
30+
GitConfiguration = new TestGitConfiguration
31+
{
32+
Global =
33+
{
34+
[GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { expectedClientId },
35+
[GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { expectedClientSecret },
36+
[GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { expectedRedirectUri.ToString() },
37+
[GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', expectedScopes) },
38+
[GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { authzEndpoint },
39+
[GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { tokenEndpoint },
40+
[GetKey(Constants.GitConfiguration.Credential.OAuthDeviceEndpoint)] = new[] { deviceEndpoint },
41+
[GetKey(Constants.GitConfiguration.Credential.OAuthDefaultUserName)] = new[] { expectedUserName },
42+
}
43+
},
44+
RemoteUri = remoteUri
45+
};
46+
47+
bool result = GenericOAuthConfig.TryGet(trace, settings, remoteUri, out GenericOAuthConfig config);
48+
49+
Assert.True(result);
50+
Assert.Equal(expectedClientId, config.ClientId);
51+
Assert.Equal(expectedClientSecret, config.ClientSecret);
52+
Assert.Equal(expectedRedirectUri, config.RedirectUri);
53+
Assert.Equal(expectedScopes, config.Scopes);
54+
Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint);
55+
Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint);
56+
Assert.Equal(expectedDeviceEndpoint, config.Endpoints.DeviceAuthorizationEndpoint);
57+
Assert.Equal(expectedUserName, config.DefaultUserName);
58+
Assert.True(config.UseAuthHeader);
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)