Skip to content
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

Preserve exact original redirect URL in OAuth client #1281

Merged
merged 1 commit into from
Jun 8, 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
49 changes: 49 additions & 0 deletions src/shared/Core.Tests/Authentication/OAuth2ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,55 @@ public async Task OAuth2Client_GetAuthorizationCodeAsync()
Assert.Equal(expectedAuthCode, result.Code);
}

[Theory]
[InlineData("http://localhost")]
[InlineData("http://localhost/")]
[InlineData("http://localhost/oauth-callback")]
[InlineData("http://localhost/oauth-callback/")]
[InlineData("http://127.0.0.1")]
[InlineData("http://127.0.0.1/")]
[InlineData("http://127.0.0.1/oauth-callback")]
[InlineData("http://127.0.0.1/oauth-callback/")]
public async Task OAuth2Client_GetAuthorizationCodeAsync_RedirectUrlOriginalStringPreserved(string expectedRedirectUrl)
{
var baseUri = new Uri("https://example.com");
OAuth2ServerEndpoints endpoints = CreateEndpoints(baseUri);

var httpHandler = new TestHttpMessageHandler {ThrowOnUnexpectedRequest = true};

OAuth2Application app = new OAuth2Application(TestClientId)
{
Secret = TestClientSecret,
RedirectUris = new[] {new Uri(expectedRedirectUrl)}
};

var server = new TestOAuth2Server(endpoints);
server.RegisterApplication(app);
server.Bind(httpHandler);
server.TokenGenerator.AuthCodes.Add("unused");
server.AuthorizationEndpointInvoked += (_, request) =>
{
IDictionary<string, string> actualParams = request.RequestUri.GetQueryParameters();
Assert.True(actualParams.TryGetValue(OAuth2Constants.RedirectUriParameter, out string actualRedirectUri));
Assert.Equal(expectedRedirectUrl, actualRedirectUri);
};

IOAuth2WebBrowser browser = new TestOAuth2WebBrowser(httpHandler);

var redirectUri = new Uri(expectedRedirectUrl);

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

await client.GetAuthorizationCodeAsync(new[] { "unused" }, browser, null, CancellationToken.None);
Comment on lines +80 to +93
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't seem to be testing any outcomes here. Are we just validating that this works with these redirect URLs?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look a little higher up at line 73:

server.AuthorizationEndpointInvoked += (_, request) =>
{
    IDictionary<string, string> actualParams = request.RequestUri.GetQueryParameters();
    Assert.True(actualParams.TryGetValue(OAuth2Constants.RedirectUriParameter, out string actualRedirectUri));
    Assert.Equal(expectedRedirectUrl, actualRedirectUri);
};

We are setting an event handler on the AuthorizationEndpointInvoked event, and performing the validation of the redirect_uri in there.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it! Thanks for clarifying.

}

[Fact]
public async Task OAuth2Client_GetAuthorizationCodeAsync_ExtraQueryParams()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
using System;
using GitCredentialManager.Authentication.OAuth;
using GitCredentialManager.Tests.Objects;
using Xunit;

namespace GitCredentialManager.Tests.Authentication;

public class OAuth2SystemWebBrowserTests
{
[Fact]
public void OAuth2SystemWebBrowser_UpdateRedirectUri_NonLoopback_ThrowsError()
{
var env = new TestEnvironment();
var options = new OAuth2WebBrowserOptions();
var browser = new OAuth2SystemWebBrowser(env, options);

Assert.Throws<ArgumentException>(() => browser.UpdateRedirectUri(new Uri("http://example.com")));
}

[Theory]
[InlineData("http://localhost:1234", "http://localhost:1234")]
[InlineData("http://localhost:1234/", "http://localhost:1234/")]
[InlineData("http://localhost:1234/oauth-callback", "http://localhost:1234/oauth-callback")]
[InlineData("http://localhost:1234/oauth-callback/", "http://localhost:1234/oauth-callback/")]
[InlineData("http://127.0.0.7:1234", "http://127.0.0.7:1234")]
[InlineData("http://127.0.0.7:1234/", "http://127.0.0.7:1234/")]
[InlineData("http://127.0.0.7:1234/oauth-callback", "http://127.0.0.7:1234/oauth-callback")]
[InlineData("http://127.0.0.7:1234/oauth-callback/", "http://127.0.0.7:1234/oauth-callback/")]
public void OAuth2SystemWebBrowser_UpdateRedirectUri_SpecificPort(string input, string expected)
{
var env = new TestEnvironment();
var options = new OAuth2WebBrowserOptions();
var browser = new OAuth2SystemWebBrowser(env, options);

Uri actualUri = browser.UpdateRedirectUri(new Uri(input));

Assert.Equal(expected, actualUri.OriginalString);
}

[Theory]
[InlineData("http://localhost")]
[InlineData("http://localhost/")]
[InlineData("http://localhost/oauth-callback")]
[InlineData("http://localhost/oauth-callback/")]
[InlineData("http://127.0.0.7")]
[InlineData("http://127.0.0.7/")]
[InlineData("http://127.0.0.7/oauth-callback")]
[InlineData("http://127.0.0.7/oauth-callback/")]
public void OAuth2SystemWebBrowser_UpdateRedirectUri_AnyPort(string input)
{
var env = new TestEnvironment();
var options = new OAuth2WebBrowserOptions();
var browser = new OAuth2SystemWebBrowser(env, options);

var inputUri = new Uri(input);
Uri actualUri = browser.UpdateRedirectUri(inputUri);

Assert.Equal(inputUri.Scheme, actualUri.Scheme);
Assert.Equal(inputUri.Host, actualUri.Host);
Assert.Equal(
inputUri.GetComponents(UriComponents.Path, UriFormat.Unescaped),
actualUri.GetComponents(UriComponents.Path, UriFormat.Unescaped)
);
Assert.False(actualUri.IsDefaultPort);
}
}
7 changes: 5 additions & 2 deletions src/shared/Core/Authentication/OAuth/OAuth2Client.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ public async Task<OAuth2AuthorizationCodeResult> GetAuthorizationCodeAsync(IEnum
if (_redirectUri != null)
{
redirectUri = browser.UpdateRedirectUri(_redirectUri);
queryParams[OAuth2Constants.RedirectUriParameter] = redirectUri.ToString();

// We must use the .OriginalString property here over .ToString() because OAuth requires the redirect
// URLs to be compared exactly, respecting missing/present trailing slashes, byte-for-byte.
queryParams[OAuth2Constants.RedirectUriParameter] = redirectUri.OriginalString;
}

string scopesStr = string.Join(" ", scopes);
Expand Down Expand Up @@ -235,7 +238,7 @@ public async Task<OAuth2TokenResult> GetTokenByAuthorizationCodeAsync(OAuth2Auth

if (authorizationCodeResult.RedirectUri != null)
{
formData[OAuth2Constants.RedirectUriParameter] = authorizationCodeResult.RedirectUri.ToString();
formData[OAuth2Constants.RedirectUriParameter] = authorizationCodeResult.RedirectUri.OriginalString;
}

if (authorizationCodeResult.CodeVerifier != null)
Expand Down
2 changes: 1 addition & 1 deletion src/shared/GitHub/GitHubConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public static class GitHubConstants

// [SuppressMessage("Microsoft.Security", "CS002:SecretInNextLine", Justification="OAuth2 public client application 'secrets' are required and permitted to be public")]
public const string OAuthClientSecret = "18867509d956965542b521a529a79bb883344c90";
public static readonly Uri OAuthRedirectUri = new Uri("http://localhost/");
public static readonly Uri OAuthRedirectUri = new Uri("http://localhost/"); // Note that the trailing slash is important!
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 static readonly Uri OAuthDeviceEndpointRelativeUri = new Uri("/login/device/code", UriKind.Relative);
Expand Down
40 changes: 21 additions & 19 deletions src/shared/TestInfrastructure/Objects/TestOAuth2Server.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ private Task<HttpResponseMessage> OnAuthorizationEndpointAsync(HttpRequestMessag
throw new Exception($"Unknown OAuth application '{clientId}'");
}

// Redirect is optional, but if it is specified it must match a registered URI
reqQuery.TryGetValue(OAuth2Constants.RedirectUriParameter, out string redirectUriStr);
Uri redirectUri = app.ValidateRedirect(redirectUriStr);
// Redirect is optional, but if it is specified it must match a registered URL
reqQuery.TryGetValue(OAuth2Constants.RedirectUriParameter, out string redirectUrlStr);
Uri redirectUri = app.ValidateRedirect(redirectUrlStr);

// Scope is optional
reqQuery.TryGetValue(OAuth2Constants.ScopeParameter, out string scopeStr);
Expand Down Expand Up @@ -104,7 +104,7 @@ private Task<HttpResponseMessage> OnAuthorizationEndpointAsync(HttpRequestMessag

// Create the auth code grant
OAuth2Application.AuthCodeGrant grant = app.CreateAuthorizationCodeGrant(
TokenGenerator, scopes, redirectUriStr, codeChallenge, codeChallengeMethod);
TokenGenerator, scopes, redirectUrlStr, codeChallenge, codeChallengeMethod);

var respQuery = new Dictionary<string, string>
{
Expand Down Expand Up @@ -527,23 +527,25 @@ public TokenEndpointResponseJson CreateTokenByDeviceCodeGrant(TestOAuth2ServerTo
};
}

private bool IsValidRedirect(Uri uri)
private bool IsValidRedirect(string url)
{
foreach (Uri redirectUri in RedirectUris)
{
if (redirectUri == uri)
// We only accept exact matches, including trailing slashes and case sensitivity
if (StringComparer.Ordinal.Equals(redirectUri.OriginalString, url))
{
return true;
}

// For localhost we ignore the port number
if (redirectUri.IsLoopback && uri.IsLoopback)
// For loopback URLs _only_ we ignore the port number
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious - why do we ignore ports for loopback urls?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to mimic real-world OAuth2 authorisation servers, which typically treat loopback addresses (localhost, 127.0.0.1, ::1) as special.

Redirect URLs are normally supposed to match exactly, including port number. This is the location where the authorisation server will redirect the user agent to, including the authorisation code in the query params for the client to consume.

For public client applications (ones that run on an end-user device), we need to get the authorisation code back to the client application from a user agent somehow. You can achieve this in two ways:

  1. Using an application-specific URL like (myapp://oauth) that is registered with the operating system, or
  2. Using a local loopback URL, and have the client application open a port listening for that HTTP request.

For option 2, you can either explicitly define a port number to use for the redirect, or leave it blank. Leaving it blank, the client will pick any random free port before it makes the initial authorisation request. Since the port may not be known ahead of time, the authorization server cannot restrict ahead of time.

Why not just pick an explicit port? Well.. which one should we use? 😜
Obviously one of the ephemeral ports (49152–65535), but there's of course a non-zero (although small) chance we pick a port that's in use by something else on the system...

if (Uri.TryCreate(url, UriKind.Absolute, out Uri uri) && uri.IsLoopback && redirectUri.IsLoopback)
{
var cmp = StringComparer.OrdinalIgnoreCase;
// *Case-sensitive* comparison of scheme, host and path
var cmp = StringComparer.Ordinal;

// Uri::Authority does not include port, whereas Uri::Host does
// Uri::Authority includes port, whereas Uri::Host does not
return cmp.Equals(redirectUri.Scheme, uri.Scheme) &&
cmp.Equals(redirectUri.Authority, uri.Authority) &&
cmp.Equals(redirectUri.Host, uri.Host) &&
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was this incorrect before?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I wanted to compare just the 'domain' part, not ports (as the comment said). But had Authority and Host backwards! 🤦

cmp.Equals(redirectUri.GetComponents(UriComponents.Path, UriFormat.UriEscaped),
uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped));
}
Expand All @@ -552,26 +554,26 @@ private bool IsValidRedirect(Uri uri)
return false;
}

internal Uri ValidateRedirect(string redirectStr)
internal Uri ValidateRedirect(string redirectUrl)
{
// Use default redirect URI if one has not been specified for this grant
if (redirectStr == null)
if (redirectUrl == null)
{
return RedirectUris.First();
}

if (!Uri.TryCreate(redirectStr, UriKind.Absolute, out Uri redirectUri))
if (!Uri.TryCreate(redirectUrl, UriKind.Absolute, out _))
{
throw new Exception($"Redirect '{redirectStr}' is not a valid URI");
throw new Exception($"Redirect '{redirectUrl}' is not a valid URL");
}

if (!IsValidRedirect(redirectUri))
if (!IsValidRedirect(redirectUrl))
{
// If a redirect URI has been specified, it must match one of those that has been previously registered
throw new Exception($"Redirect URI '{redirectUri}' does not match any registered values.");
// If a redirect URL has been specified, it must match one of those that has been previously registered
throw new Exception($"Redirect URL '{redirectUrl}' does not match any registered values.");
}

return redirectUri;
return new Uri(redirectUrl);
}
}
}