Skip to content

Add OAuth support to generic host provider #1062

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

Merged
merged 10 commits into from
Feb 1, 2023
Merged
Show file tree
Hide file tree
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
4 changes: 3 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The following are links to GCM user support documentation:
- [Host provider specification][gcm-host-provider]
- [Azure Repos OAuth tokens][gcm-azure-tokens]
- [GitLab support][gcm-gitlab]
- [Generic OAuth support][gcm-oauth]

[gcm-azure-tokens]: azrepos-users-and-tokens.md
[gcm-config]: configuration.md
Expand All @@ -23,4 +24,5 @@ The following are links to GCM user support documentation:
[gcm-gitlab]: gitlab.md
[gcm-host-provider]: hostprovider.md
[gcm-net-config]: netconfig.md
[gcm-usage]: usage.md
[gcm-oauth]: generic-oauth.md
[gcm-usage]: usage.md
116 changes: 116 additions & 0 deletions docs/generic-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Generic Host Provider OAuth

Many Git hosts use the popular standard OAuth2 or OpenID Connect (OIDC)
authentication mechanisms to secure repositories they host.
Git Credential Manager supports any generic OAuth2-based Git host by simply
setting some configuration.

## Registering an OAuth application

In order to use GCM with a Git host that supports OAuth you must first have
registered an OAuth application with your host. The instructions on how to do
this can be found with your Git host provider's documentation.

When registering a new application, you should make sure to set an HTTP-based
redirect URL that points to `localhost`; for example:

```text
http://localhost
http://localhost:<port>
http://127.0.0.1
http://127.0.0.1:<port>
```

Note that you cannot use an HTTPS redirect URL. GCM does not require a specific
port number be used; if your Git host requires you to specify a port number in
the redirect URL then GCM will use that. Otherwise an available port will be
selected at the point authentication starts.

You must ensure that all scopes required to read and write to Git repositories
have been granted for the application or else credentials that are generated
will cause errors when pushing or fetching using Git.

As part of the registration process you should also be given a Client ID and,
optionally, a Client Secret. You will need both of these to configure GCM.

## Configure GCM

In order to configure GCM to use OAuth with your Git host you need to set the
following values in your Git configuration:

- Client ID
- Client Secret (optional)
- Redirect URL
- Scopes (optional)
- OAuth Endpoints
- Authorization Endpoint
- Token Endpoint
- Device Code Authorization Endpoint (optional)

OAuth endpoints can be found by consulting your Git host's OAuth app development
documentation. The URLs can be either absolute or relative to the host name;
for example: `https://example.com/oauth/authorize` or `/oauth/authorize`.

In order to set these values, you can run the following commands, where `<HOST>`
is the hostname of your Git host:

```shell
git config --global credential.<HOST>.oauthClientId <ClientID>
git config --global credential.<HOST>.oauthClientSecret <ClientSecret>
git config --global credential.<HOST>.oauthRedirectUri <RedirectURL>
git config --global credential.<HOST>.oauthAuthorizeEndpoint <AuthEndpoint>
git config --global credential.<HOST>.oauthTokenEndpoint <TokenEndpoint>
git config --global credential.<HOST>.oauthScopes <Scopes>
git config --global credential.<HOST>.oauthDeviceEndpoint <DeviceEndpoint>
```

**Example commands:**

- `git config --global credential.https://example.com.oauthClientId C33F2751FB76`

- `git config --global credential.https://example.com.oauthScopes "code:write profile:read"`

**Example Git configuration**

```ini
[credential "https://example.com"]
oauthClientId = 9d886e36-5771-4f2b-8c8b-420c68ad5baa
oauthClientSecret = 4BC5BD4704EAE28FD832
oauthRedirectUri = "http://127.0.0.1"
oauthAuthorizeEndpoint = "/login/oauth/authorize"
oauthTokenEndpoint = "/login/oauth/token"
oauthDeviceEndpoint = "/login/oauth/device"
oauthScopes = "code:write profile:read"
oauthDefaultUserName = "OAUTH"
oauthUseClientAuthHeader = false
```

### Additional configuration

Depending on the specific implementation of OAuth with your Git host you may
also need to specify additional behavior.

#### Token user name

If your Git host requires that you specify a username to use with OAuth tokens
you can either include the username in the Git remote URL, or specify a default
option via Git configuration.

Example Git remote with username: `https://[email protected]/repo.git`.
In order to use special characters you need to URL encode the values; for
example `@` becomes `%40`.

By default GCM uses the value `OAUTH-USER` unless specified in the remote URL,
or overriden using the `credential.<HOST>.oauthDefaultUserName` configuration.

#### Include client authentication in headers

If your Git host's OAuth implementation has specific requirements about whether
the client ID and secret should or should not be included in an `Authorization`
header during OAuth requests, you can control this using the following setting:

```shell
git config --global credential.<HOST>.oauthUseClientAuthHeader <true|false>
```

The default behavior is to include these values; i.e., `true`.
101 changes: 96 additions & 5 deletions src/shared/Core.Tests/GenericHostProviderTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using GitCredentialManager.Authentication;
using GitCredentialManager.Authentication.OAuth;
using GitCredentialManager.Tests.Objects;
using Moq;
using Xunit;
Expand Down Expand Up @@ -87,8 +89,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotAllowed_Return
.ReturnsAsync(basicCredential)
.Verifiable();
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
var oauthMock = new Mock<IOAuthAuthentication>();

var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);

ICredential credential = await provider.GenerateCredentialAsync(input);

Expand Down Expand Up @@ -121,8 +124,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_LegacyAuthorityBasic
.ReturnsAsync(basicCredential)
.Verifiable();
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
var oauthMock = new Mock<IOAuthAuthentication>();

var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);

ICredential credential = await provider.GenerateCredentialAsync(input);

Expand Down Expand Up @@ -152,8 +156,9 @@ public async Task GenericHostProvider_CreateCredentialAsync_NonHttpProtocol_Retu
.ReturnsAsync(basicCredential)
.Verifiable();
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
var oauthMock = new Mock<IOAuthAuthentication>();

var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);

ICredential credential = await provider.GenerateCredentialAsync(input);

Expand Down Expand Up @@ -182,6 +187,90 @@ public async Task GenericHostProvider_CreateCredentialAsync_WiaNotSupported_Retu
await TestCreateCredentialAsync_ReturnsBasicCredential(wiaSupported: false);
}

[Fact]
public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAuthConfig_UsesOAuth()
{
var input = new InputArguments(new Dictionary<string, string>
{
["protocol"] = "https",
["host"] = "git.example.com",
["path"] = "foo"
});

const string testUserName = "TEST_OAUTH_USER";
const string testAcessToken = "OAUTH_TOKEN";
const string testRefreshToken = "OAUTH_REFRESH_TOKEN";
const string testResource = "https://git.example.com/foo";
const string expectedRefreshTokenService = "https://refresh_token.git.example.com/foo";

var authMode = OAuthAuthenticationModes.Browser;
string[] scopes = { "code:write", "code:read" };
string clientId = "3eadfc62-9e91-45d3-8c60-20ccd6d0c7cf";
string clientSecret = "C1DA8B93CCB5F5B93DA";
string redirectUri = "http://localhost";
string authzEndpoint = "/oauth/authorize";
string tokenEndpoint = "/oauth/token";
string deviceEndpoint = "/oauth/device";

string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}";

var context = new TestCommandContext
{
Git =
{
Configuration =
{
Global =
{
[GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { clientId },
[GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { clientSecret },
[GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { redirectUri },
[GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', scopes) },
[GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { authzEndpoint },
[GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { tokenEndpoint },
[GetKey(Constants.GitConfiguration.Credential.OAuthDeviceEndpoint)] = new[] { deviceEndpoint },
[GetKey(Constants.GitConfiguration.Credential.OAuthDefaultUserName)] = new[] { testUserName },
}
}
},
Settings =
{
RemoteUri = new Uri(testResource)
}
};

var basicAuthMock = new Mock<IBasicAuthentication>();
var wiaAuthMock = new Mock<IWindowsIntegratedAuthentication>();
var oauthMock = new Mock<IOAuthAuthentication>();
oauthMock.Setup(x =>
x.GetAuthenticationModeAsync(It.IsAny<string>(), It.IsAny<OAuthAuthenticationModes>()))
.ReturnsAsync(authMode);
oauthMock.Setup(x => x.GetTokenByBrowserAsync(It.IsAny<OAuth2Client>(), It.IsAny<string[]>()))
.ReturnsAsync(new OAuth2TokenResult(testAcessToken, "access_token")
{
Scopes = scopes,
RefreshToken = testRefreshToken
});

var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);

ICredential credential = await provider.GenerateCredentialAsync(input);

Assert.NotNull(credential);
Assert.Equal(testUserName, credential.Account);
Assert.Equal(testAcessToken, credential.Password);

Assert.True(context.CredentialStore.TryGet(expectedRefreshTokenService, null, out TestCredential refreshToken));
Assert.Equal(testUserName, refreshToken.Account);
Assert.Equal(testRefreshToken, refreshToken.Password);

oauthMock.Verify(x => x.GetAuthenticationModeAsync(testResource, OAuthAuthenticationModes.All), Times.Once);
oauthMock.Verify(x => x.GetTokenByBrowserAsync(It.IsAny<OAuth2Client>(), scopes), Times.Once);
oauthMock.Verify(x => x.GetTokenByDeviceCodeAsync(It.IsAny<OAuth2Client>(), scopes), Times.Never);
wiaAuthMock.Verify(x => x.GetIsSupportedAsync(It.IsAny<Uri>()), Times.Never);
basicAuthMock.Verify(x => x.GetCredentialsAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}

#region Helpers

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

var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);

ICredential credential = await provider.GenerateCredentialAsync(input);

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

var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object);
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);

ICredential credential = await provider.GenerateCredentialAsync(input);

Expand Down
61 changes: 61 additions & 0 deletions src/shared/Core.Tests/GenericOAuthConfigTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;
using GitCredentialManager.Tests.Objects;
using Xunit;

namespace GitCredentialManager.Tests
{
public class GenericOAuthConfigTests
{
[Fact]
public void GenericOAuthConfig_TryGet_Valid_ReturnsTrue()
{
var remoteUri = new Uri("https://example.com");
const string expectedClientId = "115845b0-77f8-4c06-a3dc-7d277381fad1";
const string expectedClientSecret = "4D35385D9F24";
const string expectedUserName = "TEST_USER";
const string authzEndpoint = "/oauth/authorize";
const string tokenEndpoint = "/oauth/token";
const string deviceEndpoint = "/oauth/device";
string[] expectedScopes = { "scope1", "scope2" };
var expectedRedirectUri = new Uri("http://localhost:12345");
var expectedAuthzEndpoint = new Uri(remoteUri, authzEndpoint);
var expectedTokenEndpoint = new Uri(remoteUri, tokenEndpoint);
var expectedDeviceEndpoint = new Uri(remoteUri, deviceEndpoint);

string GetKey(string name) => $"{Constants.GitConfiguration.Credential.SectionName}.https://example.com.{name}";

var trace = new NullTrace();
var settings = new TestSettings
{
GitConfiguration = new TestGitConfiguration
{
Global =
{
[GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { expectedClientId },
[GetKey(Constants.GitConfiguration.Credential.OAuthClientSecret)] = new[] { expectedClientSecret },
[GetKey(Constants.GitConfiguration.Credential.OAuthRedirectUri)] = new[] { expectedRedirectUri.ToString() },
[GetKey(Constants.GitConfiguration.Credential.OAuthScopes)] = new[] { string.Join(' ', expectedScopes) },
[GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { authzEndpoint },
[GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { tokenEndpoint },
[GetKey(Constants.GitConfiguration.Credential.OAuthDeviceEndpoint)] = new[] { deviceEndpoint },
[GetKey(Constants.GitConfiguration.Credential.OAuthDefaultUserName)] = new[] { expectedUserName },
}
},
RemoteUri = remoteUri
};

bool result = GenericOAuthConfig.TryGet(trace, settings, remoteUri, out GenericOAuthConfig config);

Assert.True(result);
Assert.Equal(expectedClientId, config.ClientId);
Assert.Equal(expectedClientSecret, config.ClientSecret);
Assert.Equal(expectedRedirectUri, config.RedirectUri);
Assert.Equal(expectedScopes, config.Scopes);
Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint);
Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint);
Assert.Equal(expectedDeviceEndpoint, config.Endpoints.DeviceAuthorizationEndpoint);
Assert.Equal(expectedUserName, config.DefaultUserName);
Assert.True(config.UseAuthHeader);
}
}
}
Loading