Skip to content
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
24 changes: 24 additions & 0 deletions docs/generic-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ following values in your Git configuration:
- Client Secret (optional)
- Redirect URL (optional, defaults to `http://127.0.0.1`)
- Scopes (optional)
- Response Mode (optional, defaults to `query`)
- OAuth Endpoints
- Authorization Endpoint
- Token Endpoint
Expand All @@ -62,6 +63,7 @@ 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>
git config --global credential.<HOST>.oauthResponseMode <ResponseMode>
```

**Example commands:**
Expand All @@ -83,13 +85,35 @@ git config --global credential.<HOST>.oauthDeviceEndpoint <DeviceEndpoint>
oauthScopes = "code:write profile:read"
oauthDefaultUserName = "OAUTH"
oauthUseClientAuthHeader = false
oauthResponseMode = "query"
```

### Additional configuration

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

#### Response mode

The response mode controls how the authorization server returns the response to
the loopback redirect URI once the user has authenticated. GCM supports the
following values:

- `query` (default) - parameters are returned in the redirect URI query string.
- `fragment` - parameters are returned in the redirect URI fragment.
- `form_post` - parameters are returned as an auto-submitting HTML form that is
POSTed to the redirect URI, as described by the
[OAuth 2.0 Form Post Response Mode][form-post-spec] specification.

Most hosts use the default `query` mode. Only set this if your host requires a
specific response mode:

```shell
git config --global credential.<HOST>.oauthResponseMode <query|fragment|form_post>
```

[form-post-spec]: https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html

#### Token user name

If your Git host requires that you specify a username to use with OAuth tokens
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode()

Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client();

MockGetAuthenticationCodeAsync(finalCallbackUri, null, client.Scopes);
MockGetAuthenticationResponseAsync(finalCallbackUri, null, client.Scopes);

MockCodeGenerator();

Expand All @@ -56,7 +56,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_RespectsClient

Bitbucket.Cloud.BitbucketOAuth2Client client = GetBitbucketOAuth2Client();

MockGetAuthenticationCodeAsync(finalCallbackUri, clientId, client.Scopes);
MockGetAuthenticationResponseAsync(finalCallbackUri, clientId, client.Scopes);

MockCodeGenerator();

Expand Down Expand Up @@ -115,7 +115,7 @@ private void MockCodeGenerator()
codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge);
}

private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrideClientId, IEnumerable<string> scopes)
private void MockGetAuthenticationResponseAsync(Uri finalCallbackUri, string overrideClientId, IEnumerable<string> scopes)
{
var authorizationUri = new UriBuilder(CloudConstants.OAuth2AuthorizationEndpoint)
{
Expand All @@ -128,7 +128,8 @@ private void MockGetAuthenticationCodeAsync(Uri finalCallbackUri, string overrid
+ "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToLower()
}.Uri;

browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, rootCallbackUri, ct)).Returns(Task.FromResult(finalCallbackUri));
browser.Setup(b => b.GetAuthenticationResponseAsync(authorizationUri, rootCallbackUri, OAuth2ResponseMode.Default, ct))
.Returns(Task.FromResult(finalCallbackUri.GetQueryParameters()));
}

private Uri MockFinalCallbackUri()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode()

var client = GetBitbucketOAuth2Client();

MockGetAuthenticationCodeAsync(remoteUrl, rootCallbackUri, finalCallbackUri, clientId, client.Scopes);
MockGetAuthenticationResponseAsync(remoteUrl, rootCallbackUri, finalCallbackUri, clientId, client.Scopes);

MockCodeGenerator();

Expand All @@ -58,7 +58,7 @@ public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode_Wh

var client = GetBitbucketOAuth2Client();

MockGetAuthenticationCodeAsync(remoteUrl, new Uri(rootCallbackUrl), finalCallbackUri, clientId, client.Scopes);
MockGetAuthenticationResponseAsync(remoteUrl, new Uri(rootCallbackUrl), finalCallbackUri, clientId, client.Scopes);

MockCodeGenerator();

Expand Down Expand Up @@ -90,7 +90,7 @@ private void MockCodeGenerator()
codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge);
}

private void MockGetAuthenticationCodeAsync(string url, Uri redirectUri, Uri finalCallbackUri, string overrideClientId, IEnumerable<string> scopes)
private void MockGetAuthenticationResponseAsync(string url, Uri redirectUri, Uri finalCallbackUri, string overrideClientId, IEnumerable<string> scopes)
{
var authorizationUri = new UriBuilder(url + "/rest/oauth2/latest/authorize")
{
Expand All @@ -103,7 +103,8 @@ private void MockGetAuthenticationCodeAsync(string url, Uri redirectUri, Uri fin
+ "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToUpper()
}.Uri;

browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, redirectUri, ct)).Returns(Task.FromResult(finalCallbackUri));
browser.Setup(b => b.GetAuthenticationResponseAsync(authorizationUri, redirectUri, OAuth2ResponseMode.Default, ct))
.Returns(Task.FromResult(finalCallbackUri.GetQueryParameters()));
}

private Uri MockFinalCallbackUri(Uri redirectUri)
Expand Down
83 changes: 83 additions & 0 deletions src/Core.Tests/Authentication/OAuth2ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,89 @@ await Assert.ThrowsAsync<ArgumentException>(() =>
client.GetAuthorizationCodeAsync(expectedScopes, browser, extraParams, CancellationToken.None));
}

[Theory]
[InlineData(OAuth2ResponseMode.Query, "query")]
[InlineData(OAuth2ResponseMode.Fragment, "fragment")]
[InlineData(OAuth2ResponseMode.FormPost, "form_post")]
public async Task OAuth2Client_GetAuthorizationCodeAsync_NonDefaultResponseMode_SendsResponseModeParameter(
OAuth2ResponseMode responseMode, string expectedValue)
{
const string expectedAuthCode = "68c39cbd8d";

var baseUri = new Uri("https://example.com");
OAuth2ServerEndpoints endpoints = CreateEndpoints(baseUri);

var httpHandler = new TestHttpMessageHandler {ThrowOnUnexpectedRequest = true};

string[] expectedScopes = {"read", "write", "delete"};

OAuth2Application app = CreateTestApplication();

var server = new TestOAuth2Server(endpoints);
server.RegisterApplication(app);
server.Bind(httpHandler);
server.TokenGenerator.AuthCodes.Add(expectedAuthCode);

server.AuthorizationEndpointInvoked += (_, request) =>
{
IDictionary<string, string> actualParams = request.RequestUri.GetQueryParameters();
Assert.True(actualParams.TryGetValue(
OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter, out string actualMode));
Assert.Equal(expectedValue, actualMode);
};

IOAuth2WebBrowser browser = new TestOAuth2WebBrowser(httpHandler);

var trace2 = new NullTrace2();
OAuth2Client client = new OAuth2Client(
new HttpClient(httpHandler), endpoints, TestClientId, trace2,
TestRedirectUri, TestClientSecret, responseMode: responseMode);

OAuth2AuthorizationCodeResult result = await client.GetAuthorizationCodeAsync(
expectedScopes, browser, null, CancellationToken.None);

Assert.Equal(expectedAuthCode, result.Code);
}

[Fact]
public async Task OAuth2Client_GetAuthorizationCodeAsync_DefaultResponseMode_OmitsResponseModeParameter()
{
const string expectedAuthCode = "68c39cbd8d";

var baseUri = new Uri("https://example.com");
OAuth2ServerEndpoints endpoints = CreateEndpoints(baseUri);

var httpHandler = new TestHttpMessageHandler {ThrowOnUnexpectedRequest = true};

string[] expectedScopes = {"read", "write", "delete"};

OAuth2Application app = CreateTestApplication();

var server = new TestOAuth2Server(endpoints);
server.RegisterApplication(app);
server.Bind(httpHandler);
server.TokenGenerator.AuthCodes.Add(expectedAuthCode);

server.AuthorizationEndpointInvoked += (_, request) =>
{
IDictionary<string, string> actualParams = request.RequestUri.GetQueryParameters();
Assert.False(actualParams.ContainsKey(
OAuth2Constants.AuthorizationEndpoint.ResponseModeParameter));
};

IOAuth2WebBrowser browser = new TestOAuth2WebBrowser(httpHandler);

var trace2 = new NullTrace2();
OAuth2Client client = new OAuth2Client(
new HttpClient(httpHandler), endpoints, TestClientId, trace2,
TestRedirectUri, TestClientSecret, responseMode: OAuth2ResponseMode.Default);

OAuth2AuthorizationCodeResult result = await client.GetAuthorizationCodeAsync(
expectedScopes, browser, null, CancellationToken.None);

Assert.Equal(expectedAuthCode, result.Code);
}

[Fact]
public async Task OAuth2Client_GetDeviceCodeAsync()
{
Expand Down
42 changes: 42 additions & 0 deletions src/Core.Tests/Authentication/OAuth2ResponseModeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using GitCredentialManager.Authentication.OAuth;
using Xunit;

namespace GitCredentialManager.Tests.Authentication;

public class OAuth2ResponseModeTests
{
[Theory]
[InlineData(OAuth2ResponseMode.Default, null)]
[InlineData(OAuth2ResponseMode.Query, "query")]
[InlineData(OAuth2ResponseMode.Fragment, "fragment")]
[InlineData(OAuth2ResponseMode.FormPost, "form_post")]
public void OAuth2ResponseMode_GetParameterValue(OAuth2ResponseMode mode, string expected)
{
Assert.Equal(expected, mode.GetParameterValue());
}

[Theory]
[InlineData("query", OAuth2ResponseMode.Query)]
[InlineData("Query", OAuth2ResponseMode.Query)]
[InlineData("fragment", OAuth2ResponseMode.Fragment)]
[InlineData("FRAGMENT", OAuth2ResponseMode.Fragment)]
[InlineData("form_post", OAuth2ResponseMode.FormPost)]
[InlineData("FORM_POST", OAuth2ResponseMode.FormPost)]
[InlineData("formpost", OAuth2ResponseMode.FormPost)]
public void OAuth2ResponseMode_TryParse_Valid(string value, OAuth2ResponseMode expected)
{
Assert.True(OAuth2ResponseModeExtensions.TryParse(value, out OAuth2ResponseMode actual));
Assert.Equal(expected, actual);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("web_message")]
[InlineData("unknown")]
public void OAuth2ResponseMode_TryParse_Invalid_ReturnsFalse(string value)
{
Assert.False(OAuth2ResponseModeExtensions.TryParse(value, out _));
}
}
16 changes: 16 additions & 0 deletions src/Core.Tests/Authentication/OAuth2SystemWebBrowserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,20 @@ public void OAuth2SystemWebBrowser_UpdateRedirectUri_AnyPort(string input)
);
Assert.False(actualUri.IsDefaultPort);
}

[Theory]
[InlineData("application/x-www-form-urlencoded", true)]
[InlineData("application/x-www-form-urlencoded; charset=utf-8", true)]
[InlineData("application/x-www-form-urlencoded;charset=UTF-8", true)]
[InlineData("APPLICATION/X-WWW-FORM-URLENCODED", true)]
[InlineData(" application/x-www-form-urlencoded ; charset=utf-8 ", true)]
[InlineData("application/json", false)]
[InlineData("text/plain; charset=utf-8", false)]
[InlineData("multipart/form-data; boundary=----abc", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void OAuth2SystemWebBrowser_IsFormUrlEncoded(string contentType, bool expected)
{
Assert.Equal(expected, OAuth2SystemWebBrowser.IsFormUrlEncoded(contentType));
}
}
71 changes: 71 additions & 0 deletions src/Core.Tests/GenericOAuthConfigTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using GitCredentialManager.Authentication.OAuth;
using GitCredentialManager.Tests.Objects;
using Xunit;

Expand Down Expand Up @@ -99,5 +100,75 @@ public void GenericOAuthConfig_TryGet_Gitea()
Assert.Equal(expectedAuthzEndpoint, config.Endpoints.AuthorizationEndpoint);
Assert.Equal(expectedTokenEndpoint, config.Endpoints.TokenEndpoint);
}

[Theory]
[InlineData("query", OAuth2ResponseMode.Query)]
[InlineData("fragment", OAuth2ResponseMode.Fragment)]
[InlineData("form_post", OAuth2ResponseMode.FormPost)]
[InlineData("FORM_POST", OAuth2ResponseMode.FormPost)]
public void GenericOAuthConfig_TryGet_ParsesResponseMode(string value, OAuth2ResponseMode expected)
{
bool result = TryGetWithResponseMode(value, out GenericOAuthConfig config);

Assert.True(result);
Assert.Equal(expected, config.ResponseMode);
}

[Fact]
public void GenericOAuthConfig_TryGet_InvalidResponseMode_FallsBackToDefault()
{
bool result = TryGetWithResponseMode("bogus", out GenericOAuthConfig config);

Assert.True(result);
Assert.Equal(OAuth2ResponseMode.Default, config.ResponseMode);
}

[Fact]
public void GenericOAuthConfig_TryGet_ResponseModeUnset_UsesDefault()
{
bool result = TryGetWithResponseMode(null, out GenericOAuthConfig config);

Assert.True(result);
Assert.Equal(OAuth2ResponseMode.Default, config.ResponseMode);
}

private static bool TryGetWithResponseMode(string responseMode, out GenericOAuthConfig config)
{
const string protocol = "https";
const string host = "example.com";
var remoteUri = new Uri($"{protocol}://{host}");

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

var trace = new NullTrace();
var gitConfig = new TestGitConfiguration
{
Global =
{
[GetKey(Constants.GitConfiguration.Credential.OAuthClientId)] = new[] { "client-id" },
[GetKey(Constants.GitConfiguration.Credential.OAuthAuthzEndpoint)] = new[] { "/oauth/authorize" },
[GetKey(Constants.GitConfiguration.Credential.OAuthTokenEndpoint)] = new[] { "/oauth/token" },
}
};

if (responseMode != null)
{
gitConfig.Global[GetKey(Constants.GitConfiguration.Credential.OAuthResponseMode)] = new[] { responseMode };
}

var settings = new TestSettings
{
GitConfiguration = gitConfig,
RemoteUri = remoteUri
};

var input = new GitRequest(new Dictionary<string, string>
{
{"protocol", protocol},
{"host", host},
});

return GenericOAuthConfig.TryGet(trace, settings, input, out config);
}
}
}
14 changes: 12 additions & 2 deletions src/Core/Authentication/OAuth/IOAuth2WebBrowser.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using System;
using System.Net;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

Expand All @@ -9,6 +9,16 @@ public interface IOAuth2WebBrowser
{
Uri UpdateRedirectUri(Uri uri);

Task<Uri> GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct);
/// <summary>
/// Drive the user agent through the authorization request and intercept the
/// authorization response delivered to the redirect URI.
/// </summary>
/// <param name="authorizationUri">Authorization request URI to open in the user agent.</param>
/// <param name="redirectUri">Redirect URI to intercept the response on.</param>
/// <param name="responseMode">Mechanism the authorization server uses to deliver the response.</param>
/// <param name="ct">Token to cancel the operation.</param>
/// <returns>The authorization response parameters.</returns>
Task<IDictionary<string, string>> GetAuthenticationResponseAsync(
Uri authorizationUri, Uri redirectUri, OAuth2ResponseMode responseMode, CancellationToken ct);
}
}
Loading
Loading