Skip to content

Commit fca0adc

Browse files
committed
oauth: add an OAuth2 client implementation
Add an OAuth2 client implementation that supports the authorization code grant, and the device authorization grant (device code) flows.
1 parent 60fc632 commit fca0adc

25 files changed

+1102
-81
lines changed

src/shared/GitHub.Tests/GitHubRestApiTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Net;
77
using System.Net.Http;
88
using System.Net.Http.Headers;
9+
using System.Text;
910
using System.Threading.Tasks;
1011
using Microsoft.Git.CredentialManager;
1112
using Microsoft.Git.CredentialManager.Tests.Objects;
@@ -380,7 +381,7 @@ public async Task GitHubRestApi_AcquireTokenAsync_UnknownResponse_ReturnsFailure
380381

381382
private static void AssertBasicAuth(HttpRequestMessage request, string userName, string password)
382383
{
383-
string expectedBasicValue = new GitCredential(userName, password).ToBase64String();
384+
string expectedBasicValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{password}"));
384385

385386
AuthenticationHeaderValue authHeader = request.Headers.Authorization;
386387
Assert.NotNull(authHeader);

src/shared/GitHub/GitHubRestApi.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ public async Task<AuthenticationResult> AcquireTokenAsync(Uri targetUri, string
4646
EnsureArgument.AbsoluteUri(targetUri, nameof(targetUri));
4747
EnsureArgument.NotNull(scopes, nameof(scopes));
4848

49-
string base64Cred = new GitCredential(username, password).ToBase64String();
50-
5149
Uri requestUri = GetAuthenticationRequestUri(targetUri);
5250

5351
_context.Trace.WriteLine($"HTTP: POST {requestUri}");
@@ -56,7 +54,7 @@ public async Task<AuthenticationResult> AcquireTokenAsync(Uri targetUri, string
5654
{
5755
// Set the request content as well as auth and 2FA headers
5856
request.Content = content;
59-
request.Headers.Authorization = new AuthenticationHeaderValue(Constants.Http.WwwAuthenticateBasicScheme, base64Cred);
57+
request.AddBasicAuthenticationHeader(username, password);
6058
if (!string.IsNullOrWhiteSpace(authenticationCode))
6159
{
6260
request.Headers.Add(GitHubConstants.GitHubOptHeader, authenticationCode);

src/shared/Microsoft.Git.CredentialManager.Tests/GitCredentialTests.cs

Lines changed: 0 additions & 61 deletions
This file was deleted.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System.Net.Http;
4+
using Xunit;
5+
6+
namespace Microsoft.Git.CredentialManager.Tests
7+
{
8+
public class HttpRequestExtensionsTests
9+
{
10+
[Fact]
11+
public void HttpRequestExtensions_AddBasicAuthenticationHeader_ComplexUserPass_ReturnsCorrectString()
12+
{
13+
const string expected = "aGVsbG8tbXlfbmFtZSBpczpqb2huLmRvZTp0aGlzIWlzQVA0U1NXMFJEOiB3aXRoPyBfbG90cyBvZi8gY2hhcnM=";
14+
const string testUserName = "hello-my_name is:john.doe";
15+
const string testPassword = "this!isAP4SSW0RD: with? _lots of/ chars";
16+
17+
TestAddBasicAuthenticationHeader(testUserName, testPassword, expected);
18+
}
19+
20+
[Fact]
21+
public void HttpRequestExtensions_AddBasicAuthenticationHeader_EmptyUserName_ReturnsCorrectString()
22+
{
23+
const string expected = "OmxldG1laW4xMjM=";
24+
const string testUserName = "";
25+
const string testPassword = "letmein123";
26+
27+
TestAddBasicAuthenticationHeader(testUserName, testPassword, expected);
28+
}
29+
30+
[Fact]
31+
public void HttpRequestExtensions_AddBasicAuthenticationHeader_EmptyPassword_ReturnsCorrectString()
32+
{
33+
const string expected = "am9obi5kb2U6";
34+
const string testUserName = "john.doe";
35+
const string testPassword = "";
36+
37+
TestAddBasicAuthenticationHeader(testUserName, testPassword, expected);
38+
}
39+
40+
[Fact]
41+
public void HttpRequestExtensions_AddBasicAuthenticationHeader_EmptyCredential_ReturnsCorrectString()
42+
{
43+
const string expected = "Og==";
44+
const string testUserName = "";
45+
const string testPassword = "";
46+
47+
TestAddBasicAuthenticationHeader(testUserName, testPassword, expected);
48+
}
49+
50+
private static void TestAddBasicAuthenticationHeader(string userName, string password, string expectedParameterValue)
51+
{
52+
var message = new HttpRequestMessage();
53+
message.AddBasicAuthenticationHeader(userName, password);
54+
55+
var authHeader = message.Headers.Authorization;
56+
Assert.NotNull(authHeader);
57+
Assert.Equal(Constants.Http.WwwAuthenticateBasicScheme, authHeader.Scheme);
58+
Assert.Equal(expectedParameterValue, authHeader.Parameter);
59+
}
60+
}
61+
}

src/shared/Microsoft.Git.CredentialManager.Tests/UriExtensionsTests.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33
using System;
4+
using System.Collections.Generic;
45
using System.Linq;
56
using Xunit;
67

78
namespace Microsoft.Git.CredentialManager.Tests
89
{
910
public class UriExtensionsTests
1011
{
12+
[Fact]
13+
public void UriExtensions_GetQueryParameters()
14+
{
15+
var uri = new Uri("https://example.com/foo/bar?q1=value1&q2=value%20with%20spaces&key%20with%20spaces=value3");
16+
17+
IDictionary<string, string> result = uri.GetQueryParameters();
18+
19+
Assert.Equal(3, result.Count);
20+
21+
Assert.True(result.TryGetValue("q1", out string value1));
22+
Assert.Equal("value1", value1);
23+
24+
Assert.True(result.TryGetValue("q2", out string value2));
25+
Assert.Equal("value with spaces", value2);
26+
27+
Assert.True(result.TryGetValue("key with spaces", out string value3));
28+
Assert.Equal("value3", value3);
29+
}
30+
1131
[Theory]
1232
[InlineData("http://com")]
1333
[InlineData("http://example.com",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System.Net;
4+
using System.Text;
5+
using System.Threading.Tasks;
6+
7+
namespace Microsoft.Git.CredentialManager.Authentication.OAuth
8+
{
9+
public static class HttpListenerExtensions
10+
{
11+
public static async Task WriteResponseAsync(this HttpListenerResponse response, string responseText)
12+
{
13+
byte[] responseData = Encoding.UTF8.GetBytes(responseText);
14+
response.ContentLength64 = responseData.Length;
15+
await response.OutputStream.WriteAsync(responseData, 0, responseData.Length);
16+
}
17+
}
18+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using System.Net;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
8+
namespace Microsoft.Git.CredentialManager.Authentication.OAuth
9+
{
10+
public interface IOAuth2WebBrowser
11+
{
12+
Task<Uri> GetAuthenticationCodeAsync(Uri authorizationUri, Uri redirectUri, CancellationToken ct);
13+
}
14+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using Newtonsoft.Json;
5+
6+
namespace Microsoft.Git.CredentialManager.Authentication.OAuth.Json
7+
{
8+
public class DeviceAuthorizationEndpointResponseJson
9+
{
10+
[JsonProperty("device_code", Required = Required.Always)]
11+
public string DeviceCode { get; set; }
12+
13+
[JsonProperty("user_code", Required = Required.Always)]
14+
public string UserCode { get; set; }
15+
16+
[JsonProperty("verification_uri", Required = Required.Always)]
17+
public Uri VerificationUri { get; set; }
18+
19+
[JsonProperty("expires_in")]
20+
[JsonConverter(typeof(TimeSpanSecondsConverter))]
21+
public TimeSpan? ExpiresIn { get; set; }
22+
23+
[JsonProperty("interval")]
24+
[JsonConverter(typeof(TimeSpanSecondsConverter))]
25+
public TimeSpan? PollingInterval { get; set; }
26+
27+
public OAuth2DeviceCodeResult ToResult()
28+
{
29+
return new OAuth2DeviceCodeResult(DeviceCode, UserCode, VerificationUri, PollingInterval)
30+
{
31+
ExpiresIn = ExpiresIn
32+
};
33+
}
34+
}
35+
}
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 System;
4+
using System.Text;
5+
using Newtonsoft.Json;
6+
7+
namespace Microsoft.Git.CredentialManager.Authentication.OAuth.Json
8+
{
9+
public class ErrorResponseJson
10+
{
11+
[JsonProperty("error", Required = Required.Always)]
12+
public string Error { get; set; }
13+
14+
[JsonProperty("error_description")]
15+
public string Description { get; set; }
16+
17+
[JsonProperty("error_uri")]
18+
public Uri Uri { get; set; }
19+
20+
public OAuth2Exception ToException(Exception innerException = null)
21+
{
22+
var message = new StringBuilder(Error);
23+
24+
if (!string.IsNullOrEmpty(Description))
25+
{
26+
message.AppendFormat(": {0}", Description);
27+
}
28+
29+
if (Uri != null)
30+
{
31+
message.AppendFormat(" [{0}]", Uri);
32+
}
33+
34+
return new OAuth2Exception(message.ToString(), innerException) {HelpLink = Uri?.ToString()};
35+
}
36+
}
37+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using Newtonsoft.Json;
5+
6+
namespace Microsoft.Git.CredentialManager.Authentication.OAuth.Json
7+
{
8+
public class TimeSpanSecondsConverter : JsonConverter<TimeSpan?>
9+
{
10+
public override void WriteJson(JsonWriter writer, TimeSpan? value, JsonSerializer serializer)
11+
{
12+
if (value.HasValue)
13+
{
14+
writer.WriteValue(value.Value.TotalSeconds);
15+
}
16+
}
17+
18+
public override TimeSpan? ReadJson(JsonReader reader, Type objectType, TimeSpan? existingValue, bool hasExistingValue, JsonSerializer serializer)
19+
{
20+
string valueString = reader.Value?.ToString();
21+
if (valueString != null)
22+
{
23+
if (int.TryParse(valueString, out int valueInt))
24+
{
25+
return TimeSpan.FromSeconds(valueInt);
26+
}
27+
}
28+
29+
return null;
30+
}
31+
}
32+
}

0 commit comments

Comments
 (0)