-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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); | ||
|
@@ -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> | ||
{ | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm curious - why do we ignore ports for loopback urls? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( 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:
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? 😜 |
||
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) && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was this incorrect before? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
cmp.Equals(redirectUri.GetComponents(UriComponents.Path, UriFormat.UriEscaped), | ||
uri.GetComponents(UriComponents.Path, UriFormat.UriEscaped)); | ||
} | ||
|
@@ -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); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
We are setting an event handler on the
AuthorizationEndpointInvoked
event
, and performing the validation of theredirect_uri
in there.There was a problem hiding this comment.
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.