Skip to content

Commit 7efe852

Browse files
authored
Merge pull request #102 from mjcheetham/oauth2-pkce
Implement RFC 7636 PKCE in OAuth client and fix redirect URI bug
2 parents 9ffdc5e + adccfaf commit 7efe852

15 files changed

+480
-101
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
2-
<s:String x:Key="/Default/Environment/Hierarchy/Build/SolBuilderDuo/UseMsbuildSolutionBuilder/@EntryValue">No</s:String></wpf:ResourceDictionary>
2+
<s:String x:Key="/Default/Environment/Hierarchy/Build/SolBuilderDuo/UseMsbuildSolutionBuilder/@EntryValue">No</s:String>
3+
<s:Boolean x:Key="/Default/UserDictionary/Words/=PKCE/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

src/shared/GitHub/GitHubAuthentication.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,9 @@ public async Task<OAuth2TokenResult> GetOAuthTokenAsync(Uri targetUri, IEnumerab
203203
// Write message to the terminal (if any is attached) for some feedback that we're waiting for a web response
204204
Context.Terminal.WriteLine("info: please complete authentication in your browser...");
205205

206-
string authCode = await oauthClient.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None);
206+
OAuth2AuthorizationCodeResult authCodeResult = await oauthClient.GetAuthorizationCodeAsync(scopes, browser, CancellationToken.None);
207207

208-
return await oauthClient.GetTokenByAuthorizationCodeAsync(authCode, CancellationToken.None);
208+
return await oauthClient.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None);
209209
}
210210
else
211211
{

src/shared/Microsoft.Git.CredentialManager.Tests/Authentication/OAuth2ClientTests.cs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ public async Task OAuth2Client_GetAuthorizationCodeAsync()
3939

4040
OAuth2Client client = CreateClient(httpHandler, endpoints);
4141

42-
string actualAuthCode = await client.GetAuthorizationCodeAsync(expectedScopes, browser, CancellationToken.None);
42+
OAuth2AuthorizationCodeResult result = await client.GetAuthorizationCodeAsync(expectedScopes, browser, CancellationToken.None);
4343

44-
Assert.Equal(expectedAuthCode, actualAuthCode);
44+
Assert.Equal(expectedAuthCode, result.Code);
4545
}
4646

4747
[Fact]
@@ -88,7 +88,7 @@ public async Task OAuth2Client_GetTokenByAuthorizationCodeAsync()
8888
string[] expectedScopes = {"read", "write", "delete"};
8989

9090
OAuth2Application app = CreateTestApplication();
91-
app.AuthCodes[authCode] = expectedScopes;
91+
app.AuthGrants.Add(new OAuth2Application.AuthCodeGrant(authCode, expectedScopes));
9292

9393
var server = new TestOAuth2Server(endpoints);
9494
server.RegisterApplication(app);
@@ -98,7 +98,8 @@ public async Task OAuth2Client_GetTokenByAuthorizationCodeAsync()
9898

9999
OAuth2Client client = CreateClient(httpHandler, endpoints);
100100

101-
OAuth2TokenResult result = await client.GetTokenByAuthorizationCodeAsync(authCode, CancellationToken.None);
101+
var authCodeResult = new OAuth2AuthorizationCodeResult(authCode, TestRedirectUri);
102+
OAuth2TokenResult result = await client.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None);
102103

103104
Assert.NotNull(result);
104105
Assert.Equal(expectedScopes, result.Scopes);
@@ -217,9 +218,10 @@ public async Task OAuth2Client_E2E_InteractiveWebFlowAndRefresh()
217218

218219
OAuth2Client client = CreateClient(httpHandler, endpoints);
219220

220-
string authCode = await client.GetAuthorizationCodeAsync(expectedScopes, browser, CancellationToken.None);
221+
OAuth2AuthorizationCodeResult authCodeResult = await client.GetAuthorizationCodeAsync(
222+
expectedScopes, browser, CancellationToken.None);
221223

222-
OAuth2TokenResult result1 = await client.GetTokenByAuthorizationCodeAsync(authCode, CancellationToken.None);
224+
OAuth2TokenResult result1 = await client.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None);
223225

224226
Assert.NotNull(result1);
225227
Assert.Equal(expectedScopes, result1.Scopes);
@@ -296,11 +298,11 @@ public async Task OAuth2Client_E2E_DeviceFlowAndRefresh()
296298
RedirectUris = new[] {TestRedirectUri}
297299
};
298300

299-
private static OAuth2Client CreateClient(HttpMessageHandler httpHandler, OAuth2ServerEndpoints endpoints, IOAuth2NonceGenerator generator = null)
301+
private static OAuth2Client CreateClient(HttpMessageHandler httpHandler, OAuth2ServerEndpoints endpoints, IOAuth2CodeGenerator generator = null)
300302
{
301303
return new OAuth2Client(new HttpClient(httpHandler), endpoints, TestClientId, TestRedirectUri, TestClientSecret)
302304
{
303-
NonceGenerator = generator
305+
CodeGenerator = generator
304306
};
305307
}
306308

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.Linq;
2+
using System.Security.Cryptography;
3+
using System.Text;
4+
using Microsoft.Git.CredentialManager.Authentication.OAuth;
5+
using Xunit;
6+
7+
namespace Microsoft.Git.CredentialManager.Tests.Authentication
8+
{
9+
public class OAuth2CryptographicCodeGeneratorTests
10+
{
11+
private const string ValidBase64UrlCharsNoPad = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
12+
13+
[Fact]
14+
public void OAuth2CryptographicCodeGenerator_CreateNonce_IsUnique()
15+
{
16+
var generator = new OAuth2CryptographicCodeGenerator();
17+
18+
// Create a bunch of nonce values
19+
var nonces = new string[32];
20+
for (int i = 0; i < nonces.Length; i++)
21+
{
22+
nonces[i] = generator.CreateNonce();
23+
}
24+
25+
// There should be no duplicates
26+
string[] uniqueNonces = nonces.Distinct().ToArray();
27+
Assert.Equal(uniqueNonces, nonces);
28+
}
29+
30+
[Fact]
31+
public void OAuth2CryptographicCodeGenerator_CreatePkceCodeVerifier_IsUniqueBase64UrlStringWithoutPaddingAndLengthBetween43And128()
32+
{
33+
var generator = new OAuth2CryptographicCodeGenerator();
34+
35+
// Create a bunch of verifiers
36+
var verifiers = new string[32];
37+
for (int i = 0; i < verifiers.Length; i++)
38+
{
39+
string v = generator.CreatePkceCodeVerifier();
40+
41+
// Assert the verifier is a base64url string without padding
42+
char[] vs = v.ToCharArray();
43+
Assert.All(vs, x => Assert.Contains(x, ValidBase64UrlCharsNoPad));
44+
45+
// Assert the verifier is a string of length [43, 128] (inclusive)
46+
Assert.InRange(v.Length, 43, 128);
47+
48+
verifiers[i] = v;
49+
}
50+
51+
// There should be no duplicates
52+
string[] uniqueVerifiers = verifiers.Distinct().ToArray();
53+
Assert.Equal(uniqueVerifiers, verifiers);
54+
}
55+
56+
[Fact]
57+
public void OAuth2CryptographicCodeGenerator_CreatePkceCodeChallenge_Plain_ReturnsVerifierUnchanged()
58+
{
59+
var generator = new OAuth2CryptographicCodeGenerator();
60+
61+
var verifier = generator.CreatePkceCodeVerifier();
62+
var challenge = generator.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Plain, verifier);
63+
64+
Assert.Equal(verifier, challenge);
65+
}
66+
67+
[Fact]
68+
public void OAuth2CryptographicCodeGenerator_CreatePkceCodeChallenge_Sha256_ReturnsBase64UrlEncodedSha256HashOfAsciiVerifier()
69+
{
70+
var generator = new OAuth2CryptographicCodeGenerator();
71+
72+
var verifier = generator.CreatePkceCodeVerifier();
73+
74+
byte[] verifierAsciiBytes = Encoding.ASCII.GetBytes(verifier);
75+
byte[] hashedBytes;
76+
using (var sha256 = SHA256.Create())
77+
{
78+
hashedBytes = sha256.ComputeHash(verifierAsciiBytes);
79+
}
80+
81+
var expectedChallenge = Base64UrlConvert.Encode(hashedBytes, false);
82+
var actualChallenge = generator.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, verifier);
83+
84+
Assert.Equal(expectedChallenge, actualChallenge);
85+
}
86+
}
87+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using Xunit;
4+
5+
namespace Microsoft.Git.CredentialManager.Tests
6+
{
7+
public class Base64UrlConvertTests
8+
{
9+
[Theory]
10+
[InlineData(new byte[0], "")]
11+
[InlineData(new byte[]{4}, "BA==")]
12+
[InlineData(new byte[]{4,5}, "BAU=")]
13+
[InlineData(new byte[]{4,5,6}, "BAUG")]
14+
[InlineData(new byte[]{4,5,6,7}, "BAUGBw==")]
15+
[InlineData(new byte[]{4,5,6,7,8}, "BAUGBwg=")]
16+
[InlineData(new byte[]{4,5,6,7,8,9}, "BAUGBwgJ")]
17+
public void Base64UrlConvert_Encode_WithPadding(byte[] data, string expected)
18+
{
19+
string actual = Base64UrlConvert.Encode(data, includePadding: true);
20+
Assert.Equal(expected, actual);
21+
}
22+
23+
[Theory]
24+
[InlineData(new byte[0], "")]
25+
[InlineData(new byte[]{4}, "BA")]
26+
[InlineData(new byte[]{4,5}, "BAU")]
27+
[InlineData(new byte[]{4,5,6}, "BAUG")]
28+
[InlineData(new byte[]{4,5,6,7}, "BAUGBw")]
29+
[InlineData(new byte[]{4,5,6,7,8}, "BAUGBwg")]
30+
[InlineData(new byte[]{4,5,6,7,8,9}, "BAUGBwgJ")]
31+
public void Base64UrlConvert_Encode_WithoutPadding(byte[] data, string expected)
32+
{
33+
string actual = Base64UrlConvert.Encode(data, includePadding: false);
34+
Assert.Equal(expected, actual);
35+
}
36+
}
37+
}

src/shared/Microsoft.Git.CredentialManager/Authentication/OAuth/IOAuth2WebBrowser.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ namespace Microsoft.Git.CredentialManager.Authentication.OAuth
99
{
1010
public interface IOAuth2WebBrowser
1111
{
12+
Uri UpdateRedirectUri(Uri uri);
13+
1214
Task<Uri> GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct);
1315
}
1416
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
5+
namespace Microsoft.Git.CredentialManager.Authentication.OAuth
6+
{
7+
public class OAuth2AuthorizationCodeResult
8+
{
9+
public OAuth2AuthorizationCodeResult(string code, Uri redirectUri = null, string codeVerifier = null)
10+
{
11+
Code = code;
12+
RedirectUri = redirectUri;
13+
CodeVerifier = codeVerifier;
14+
}
15+
16+
public string Code { get; }
17+
public Uri RedirectUri { get; }
18+
public string CodeVerifier { get; }
19+
}
20+
}

src/shared/Microsoft.Git.CredentialManager/Authentication/OAuth/OAuth2Client.cs

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22
// Licensed under the MIT license.
33
using System;
44
using System.Collections.Generic;
5-
using System.Net;
65
using System.Net.Http;
76
using System.Net.Http.Headers;
8-
using System.Text;
97
using System.Threading;
108
using System.Threading.Tasks;
119
using Microsoft.Git.CredentialManager.Authentication.OAuth.Json;
@@ -26,7 +24,7 @@ public interface IOAuth2Client
2624
/// <param name="browser">User agent to use to start the authorization code grant flow.</param>
2725
/// <param name="ct">Token to cancel the operation.</param>
2826
/// <returns>Authorization code.</returns>
29-
Task<string> GetAuthorizationCodeAsync(IEnumerable<string> scopes, IOAuth2WebBrowser browser, CancellationToken ct);
27+
Task<OAuth2AuthorizationCodeResult> GetAuthorizationCodeAsync(IEnumerable<string> scopes, IOAuth2WebBrowser browser, CancellationToken ct);
3028

3129
/// <summary>
3230
/// Retrieve a device code grant.
@@ -40,10 +38,10 @@ public interface IOAuth2Client
4038
/// <summary>
4139
/// Exchange an authorization code acquired from <see cref="GetAuthorizationCodeAsync"/> for an access token.
4240
/// </summary>
43-
/// <param name="authorizationCode">Authorization code.</param>
41+
/// <param name="authorizationCodeResult">Authorization code grant result.</param>
4442
/// <param name="ct">Token to cancel the operation.</param>
4543
/// <returns>Token result.</returns>
46-
Task<OAuth2TokenResult> GetTokenByAuthorizationCodeAsync(string authorizationCode, CancellationToken ct);
44+
Task<OAuth2TokenResult> GetTokenByAuthorizationCodeAsync(OAuth2AuthorizationCodeResult authorizationCodeResult, CancellationToken ct);
4745

4846
/// <summary>
4947
/// Use a refresh token to get a new access token.
@@ -70,7 +68,7 @@ public class OAuth2Client : IOAuth2Client
7068
private readonly string _clientId;
7169
private readonly string _clientSecret;
7270

73-
private IOAuth2NonceGenerator _nonceGenerator;
71+
private IOAuth2CodeGenerator _codeGenerator;
7472

7573
public OAuth2Client(HttpClient httpClient, OAuth2ServerEndpoints endpoints, string clientId, Uri redirectUri = null, string clientSecret = null)
7674
{
@@ -81,31 +79,39 @@ public OAuth2Client(HttpClient httpClient, OAuth2ServerEndpoints endpoints, stri
8179
_clientSecret = clientSecret;
8280
}
8381

84-
public IOAuth2NonceGenerator NonceGenerator
82+
public IOAuth2CodeGenerator CodeGenerator
8583
{
86-
get => _nonceGenerator ?? (_nonceGenerator = new OAuth2NonceGenerator());
87-
set => _nonceGenerator = value;
84+
get => _codeGenerator ?? (_codeGenerator = new OAuth2CryptographicCodeGenerator());
85+
set => _codeGenerator = value;
8886
}
8987

9088
#region IOAuth2Client
9189

92-
public async Task<string> GetAuthorizationCodeAsync(IEnumerable<string> scopes, IOAuth2WebBrowser browser, CancellationToken ct)
90+
public async Task<OAuth2AuthorizationCodeResult> GetAuthorizationCodeAsync(IEnumerable<string> scopes, IOAuth2WebBrowser browser, CancellationToken ct)
9391
{
94-
string state = NonceGenerator.CreateNonce();
95-
string scopesStr = string.Join(" ", scopes);
92+
string state = CodeGenerator.CreateNonce();
93+
string codeVerifier = CodeGenerator.CreatePkceCodeVerifier();
94+
string codeChallenge = CodeGenerator.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, codeVerifier);
9695

9796
var queryParams = new Dictionary<string, string>
9897
{
99-
[OAuth2Constants.AuthorizationEndpoint.ResponseTypeParameter] = OAuth2Constants.AuthorizationEndpoint.AuthorizationCodeResponseType,
98+
[OAuth2Constants.AuthorizationEndpoint.ResponseTypeParameter] =
99+
OAuth2Constants.AuthorizationEndpoint.AuthorizationCodeResponseType,
100100
[OAuth2Constants.ClientIdParameter] = _clientId,
101101
[OAuth2Constants.AuthorizationEndpoint.StateParameter] = state,
102+
[OAuth2Constants.AuthorizationEndpoint.PkceChallengeMethodParameter] =
103+
OAuth2Constants.AuthorizationEndpoint.PkceChallengeMethodS256,
104+
[OAuth2Constants.AuthorizationEndpoint.PkceChallengeParameter] = codeChallenge
102105
};
103106

107+
Uri redirectUri = null;
104108
if (_redirectUri != null)
105109
{
106-
queryParams[OAuth2Constants.RedirectUriParameter] = _redirectUri.ToString();
110+
redirectUri = browser.UpdateRedirectUri(_redirectUri);
111+
queryParams[OAuth2Constants.RedirectUriParameter] = redirectUri.ToString();
107112
}
108113

114+
string scopesStr = string.Join(" ", scopes);
109115
if (!string.IsNullOrWhiteSpace(scopesStr))
110116
{
111117
queryParams[OAuth2Constants.ScopeParameter] = scopesStr;
@@ -119,12 +125,12 @@ public async Task<string> GetAuthorizationCodeAsync(IEnumerable<string> scopes,
119125
Uri authorizationUri = authorizationUriBuilder.Uri;
120126

121127
// Open the browser at the request URI to start the authorization code grant flow.
122-
Uri redirectUri = await browser.GetAuthenticationCodeAsync(authorizationUri, _redirectUri, ct);
128+
Uri finalUri = await browser.GetAuthenticationCodeAsync(authorizationUri, redirectUri, ct);
123129

124130
// Check for errors serious enough we should terminate the flow, such as if the state value returned does
125131
// not match the one we passed. This indicates a badly implemented Authorization Server, or worse, some
126132
// form of failed MITM or replay attack.
127-
IDictionary<string, string> redirectQueryParams = redirectUri.GetQueryParameters();
133+
IDictionary<string, string> redirectQueryParams = finalUri.GetQueryParameters();
128134
if (!redirectQueryParams.TryGetValue(OAuth2Constants.AuthorizationGrantResponse.StateParameter, out string replyState))
129135
{
130136
throw new OAuth2Exception($"Missing '{OAuth2Constants.AuthorizationGrantResponse.StateParameter}' in response.");
@@ -140,7 +146,7 @@ public async Task<string> GetAuthorizationCodeAsync(IEnumerable<string> scopes,
140146
throw new OAuth2Exception($"Missing '{OAuth2Constants.AuthorizationGrantResponse.AuthorizationCodeParameter}' in response.");
141147
}
142148

143-
return authCode;
149+
return new OAuth2AuthorizationCodeResult(authCode, redirectUri, codeVerifier);
144150
}
145151

146152
public async Task<OAuth2DeviceCodeResult> GetDeviceCodeAsync(IEnumerable<string> scopes, CancellationToken ct)
@@ -177,18 +183,24 @@ public async Task<OAuth2DeviceCodeResult> GetDeviceCodeAsync(IEnumerable<string>
177183
}
178184
}
179185

180-
public async Task<OAuth2TokenResult> GetTokenByAuthorizationCodeAsync(string authorizationCode, CancellationToken ct)
186+
public async Task<OAuth2TokenResult> GetTokenByAuthorizationCodeAsync(OAuth2AuthorizationCodeResult authorizationCodeResult, CancellationToken ct)
181187
{
182188
var formData = new Dictionary<string, string>
183189
{
184190
[OAuth2Constants.TokenEndpoint.GrantTypeParameter] = OAuth2Constants.TokenEndpoint.AuthorizationCodeGrantType,
185-
[OAuth2Constants.TokenEndpoint.AuthorizationCodeParameter] = authorizationCode,
186-
[OAuth2Constants.ClientIdParameter] = _clientId,
191+
[OAuth2Constants.TokenEndpoint.AuthorizationCodeParameter] = authorizationCodeResult.Code,
192+
[OAuth2Constants.TokenEndpoint.PkceVerifierParameter] = authorizationCodeResult.CodeVerifier,
193+
[OAuth2Constants.ClientIdParameter] = _clientId
187194
};
188195

189-
if (_redirectUri != null)
196+
if (authorizationCodeResult.RedirectUri != null)
190197
{
191-
formData[OAuth2Constants.RedirectUriParameter] = _redirectUri.ToString();
198+
formData[OAuth2Constants.RedirectUriParameter] = authorizationCodeResult.RedirectUri.ToString();
199+
}
200+
201+
if (authorizationCodeResult.CodeVerifier != null)
202+
{
203+
formData[OAuth2Constants.TokenEndpoint.PkceVerifierParameter] = authorizationCodeResult.CodeVerifier;
192204
}
193205

194206
using (HttpContent requestContent = new FormUrlEncodedContent(formData))

src/shared/Microsoft.Git.CredentialManager/Authentication/OAuth/OAuth2Constants.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ public static class AuthorizationEndpoint
1414
public const string StateParameter = "state";
1515
public const string AuthorizationCodeResponseType = "code";
1616
public const string ResponseTypeParameter = "response_type";
17+
public const string PkceChallengeParameter = "code_challenge";
18+
public const string PkceChallengeMethodParameter = "code_challenge_method";
19+
public const string PkceChallengeMethodPlain = "plain";
20+
public const string PkceChallengeMethodS256 = "S256";
1721
}
1822

1923
public static class AuthorizationGrantResponse
@@ -30,7 +34,7 @@ public static class TokenEndpoint
3034
public const string GrantTypeParameter = "grant_type";
3135
public const string AuthorizationCodeGrantType = "authorization_code";
3236
public const string RefreshTokenGrantType = "refresh_token";
33-
37+
public const string PkceVerifierParameter = "code_verifier";
3438
public const string AuthorizationCodeParameter = "code";
3539
public const string RefreshTokenParameter = "refresh_token";
3640
}

0 commit comments

Comments
 (0)