Skip to content

Commit 2f88662

Browse files
committed
githubauth: add wwwauth header parsing
Add parsing of GitHub.com's WWW-Authenticate header, with the upcoming enterprise_hint and domain_hint properties that can be used to indicate when a resource (repository) requires a specific EMU account.
1 parent 0ae7f47 commit 2f88662

File tree

3 files changed

+274
-0
lines changed

3 files changed

+274
-0
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Xunit;
4+
5+
namespace GitHub.Tests;
6+
7+
public class GitHubAuthChallengeTests
8+
{
9+
[Fact]
10+
public void GitHubAuthChallenge_FromHeaders_CaseInsensitive()
11+
{
12+
var headers = new[]
13+
{
14+
"BASIC REALM=\"GITHUB\"",
15+
"basic realm=\"github\"",
16+
"bAsIc ReAlM=\"gItHuB\"",
17+
};
18+
19+
IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
20+
Assert.Equal(3, challenges.Count);
21+
22+
foreach (var challenge in challenges)
23+
{
24+
Assert.Null(challenge.Domain);
25+
Assert.Null(challenge.Enterprise);
26+
}
27+
}
28+
29+
[Fact]
30+
public void GitHubAuthChallenge_FromHeaders_MultipleRealms_ReturnsGitHubOnly()
31+
{
32+
var headers = new[]
33+
{
34+
"Basic realm=\"contoso\"",
35+
"Basic realm=\"GitHub\"",
36+
"Basic realm=\"fabrikam\"",
37+
};
38+
39+
IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
40+
Assert.Single(challenges);
41+
42+
Assert.Null(challenges[0].Domain);
43+
Assert.Null(challenges[0].Enterprise);
44+
}
45+
46+
[Fact]
47+
public void GitHubAuthChallenge_FromHeaders_NoMatchingRealms_ReturnsEmpty()
48+
{
49+
var headers = new[]
50+
{
51+
"Basic realm=\"contoso\"",
52+
"Basic realm=\"fabrikam\"",
53+
"Basic realm=\"example\"",
54+
};
55+
56+
IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
57+
Assert.Empty(challenges);
58+
}
59+
60+
[Theory]
61+
[InlineData("Basic realm=\"GitHub\" enterprise_hint=\"contoso-corp\" domain_hint=\"contoso\"", "contoso", "contoso-corp")]
62+
[InlineData("Basic realm=\"GitHub\" domain_hint=\"contoso\"", "contoso", null)]
63+
[InlineData("Basic realm=\"GitHub\" enterprise_hint=\"contoso-corp\"", null, "contoso-corp")]
64+
[InlineData("Basic realm=\"GitHub\" domain_hint=\"fab\" enterprise_hint=\"fabirkamopensource\"", "fab", "fabirkamopensource")]
65+
[InlineData("Basic enterprise_hint=\"iana\" realm=\"GitHub\" domain_hint=\"example\"", "example", "iana")]
66+
[InlineData("Basic domain_hint=\"test\" enterprise_hint=\"test-inc\" realm=\"GitHub\"", "test", "test-inc")]
67+
public void GitHubAuthChallenge_FromHeaders_Hints_ReturnsWithHints(string header, string domain, string enterprise)
68+
{
69+
IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(new[] { header });
70+
Assert.Single(challenges);
71+
72+
Assert.Equal(domain, challenges[0].Domain);
73+
Assert.Equal(enterprise, challenges[0].Enterprise);
74+
}
75+
76+
[Fact]
77+
public void GitHubAuthChallenge_FromHeaders_EmptyHeaders_ReturnsEmpty()
78+
{
79+
string[] headers = Array.Empty<string>();
80+
IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
81+
Assert.Empty(challenges);
82+
}
83+
84+
[Theory]
85+
[InlineData(null, false)]
86+
[InlineData("", false)]
87+
[InlineData(" ", false)]
88+
[InlineData("alice", true)]
89+
[InlineData("alice_contoso", false)]
90+
[InlineData("alice_CONTOSO", false)]
91+
[InlineData("alice_contoso_alt", false)]
92+
[InlineData("pj_nitin", true)]
93+
[InlineData("up_the_irons", true)]
94+
public void GitHubAuthChallenge_IsDomainMember_NoHint(string userName, bool expected)
95+
{
96+
var challenge = new GitHubAuthChallenge();
97+
Assert.Equal(expected, challenge.IsDomainMember(userName));
98+
}
99+
100+
[Theory]
101+
[InlineData(null, false)]
102+
[InlineData("", false)]
103+
[InlineData(" ", false)]
104+
[InlineData("alice", false)]
105+
[InlineData("alice_contoso", true)]
106+
[InlineData("alice_CONTOSO", true)]
107+
[InlineData("alice_contoso_alt", false)]
108+
[InlineData("pj_nitin", false)]
109+
[InlineData("up_the_irons", false)]
110+
public void GitHubAuthChallenge_IsDomainMember_DomainHint(string userName, bool expected)
111+
{
112+
var realm = new GitHubAuthChallenge("contoso", "contoso-corp");
113+
Assert.Equal(expected, realm.IsDomainMember(userName));
114+
}
115+
116+
[Fact]
117+
public void GitHubAuthChallenge_Equals_Null_ReturnsFalse()
118+
{
119+
var challenge = new GitHubAuthChallenge("contoso", "contoso-corp");
120+
Assert.False(challenge.Equals(null));
121+
}
122+
123+
[Fact]
124+
public void GitHubAuthChallenge_Equals_SameInstance_ReturnsTrue()
125+
{
126+
var challenge = new GitHubAuthChallenge("contoso", "contoso-corp");
127+
Assert.True(challenge.Equals(challenge));
128+
}
129+
130+
[Fact]
131+
public void GitHubAuthChallenge_Equals_DifferentInstance_ReturnsTrue()
132+
{
133+
var challenge1 = new GitHubAuthChallenge("contoso", "constoso-corp");
134+
var challenge2 = new GitHubAuthChallenge("contoso", "constoso-corp");
135+
Assert.True(challenge1.Equals(challenge2));
136+
}
137+
138+
[Fact]
139+
public void GitHubAuthChallenge_Equals_DifferentCase_ReturnsTrue()
140+
{
141+
var challenge1 = new GitHubAuthChallenge("contoso", "contoso-corp");
142+
var challenge2 = new GitHubAuthChallenge("CONTOSO", "CONTOSO-CORP");
143+
Assert.True(challenge1.Equals(challenge2));
144+
}
145+
146+
[Fact]
147+
public void GitHubAuthChallenge_Equals_DifferentShortCode_ReturnsFalse()
148+
{
149+
var challenge1 = new GitHubAuthChallenge("contoso", "constoso-corp");
150+
var challenge2 = new GitHubAuthChallenge("fab", "fabrikamopensource");
151+
Assert.False(challenge1.Equals(challenge2));
152+
}
153+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text.RegularExpressions;
5+
6+
namespace GitHub;
7+
8+
public class GitHubAuthChallenge : IEquatable<GitHubAuthChallenge>
9+
{
10+
private static readonly Regex BasicRegex = new(@"Basic\s+(?'props1'.*)realm=""GitHub""(?'props2'.*)",
11+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
12+
13+
public static IList<GitHubAuthChallenge> FromHeaders(IEnumerable<string> headers)
14+
{
15+
var challenges = new List<GitHubAuthChallenge>();
16+
foreach (string header in headers)
17+
{
18+
var match = BasicRegex.Match(header);
19+
if (match.Success)
20+
{
21+
IDictionary<string, string> props = ParseProperties(match.Groups["props1"].Value + match.Groups["props2"]);
22+
23+
// The enterprise shortcode is provided in the `domain_hint` property, whereas the
24+
// enterprise name/slug is provided in the `enterprise_hint` property.
25+
props.TryGetValue("domain_hint", out string domain);
26+
props.TryGetValue("enterprise_hint", out string enterprise);
27+
28+
var challenge = new GitHubAuthChallenge(domain, enterprise);
29+
30+
challenges.Add(challenge);
31+
}
32+
}
33+
34+
return challenges;
35+
}
36+
37+
private static IDictionary<string, string> ParseProperties(string str)
38+
{
39+
var props = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
40+
foreach (string prop in str.Split(' '))
41+
{
42+
int delim = prop.IndexOf('=');
43+
if (delim < 0)
44+
{
45+
continue;
46+
}
47+
48+
string key = prop.Substring(0, delim).Trim();
49+
string value = prop.Substring(delim + 1).Trim('"');
50+
51+
props[key] = value;
52+
}
53+
54+
return props;
55+
}
56+
57+
public GitHubAuthChallenge() { }
58+
59+
public GitHubAuthChallenge(string domain, string enterprise)
60+
{
61+
Domain = domain;
62+
Enterprise = enterprise;
63+
}
64+
65+
public string Domain { get; }
66+
67+
public string Enterprise { get; }
68+
69+
public bool IsDomainMember(string userName)
70+
{
71+
if (string.IsNullOrWhiteSpace(userName))
72+
{
73+
return false;
74+
}
75+
76+
int delim = userName.LastIndexOf('_');
77+
if (delim < 0)
78+
{
79+
return string.IsNullOrWhiteSpace(Domain);
80+
}
81+
82+
// Check for users that contain underscores but are not EMU logins
83+
if (GitHubConstants.InvalidUnderscoreLogins.Contains(userName, StringComparer.OrdinalIgnoreCase))
84+
{
85+
return string.IsNullOrWhiteSpace(Domain);
86+
}
87+
88+
string shortCode = userName.Substring(delim + 1);
89+
return StringComparer.OrdinalIgnoreCase.Equals(Domain, shortCode);
90+
}
91+
92+
public bool Equals(GitHubAuthChallenge other)
93+
{
94+
if (ReferenceEquals(null, other)) return false;
95+
if (ReferenceEquals(this, other)) return true;
96+
return string.Equals(Domain, other.Domain, StringComparison.OrdinalIgnoreCase) &&
97+
string.Equals(Enterprise, other.Enterprise, StringComparison.OrdinalIgnoreCase);
98+
}
99+
100+
public override bool Equals(object obj)
101+
{
102+
if (ReferenceEquals(null, obj)) return false;
103+
if (ReferenceEquals(this, obj)) return true;
104+
if (obj.GetType() != GetType()) return false;
105+
return Equals((GitHubAuthChallenge)obj);
106+
}
107+
108+
public override int GetHashCode()
109+
{
110+
return Domain.GetHashCode() * 1019 ^
111+
Enterprise.GetHashCode() * 337;
112+
}
113+
}

src/shared/GitHub/GitHubConstants.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23

34
namespace GitHub
45
{
@@ -19,6 +20,11 @@ public static class GitHubConstants
1920
public static readonly Uri OAuthTokenEndpointRelativeUri = new Uri("/login/oauth/access_token", UriKind.Relative);
2021
public static readonly Uri OAuthDeviceEndpointRelativeUri = new Uri("/login/device/code", UriKind.Relative);
2122

23+
/// <summary>
24+
/// GitHub user names that contain underscores but are not EMU logins.
25+
/// </summary>
26+
public static readonly IReadOnlyList<string> InvalidUnderscoreLogins = new[] { "pj_nitin", "up_the_irons" };
27+
2228
/// <summary>
2329
/// The GitHub required HTTP accepts header value
2430
/// </summary>
@@ -59,6 +65,7 @@ public static class EnvironmentVariables
5965
public const string DevOAuthClientId = "GCM_DEV_GITHUB_CLIENTID";
6066
public const string DevOAuthClientSecret = "GCM_DEV_GITHUB_CLIENTSECRET";
6167
public const string DevOAuthRedirectUri = "GCM_DEV_GITHUB_REDIRECTURI";
68+
public const string AccountFiltering = "GCM_GITHUB_ACCOUNTFILTERING";
6269
}
6370

6471
public static class GitConfiguration
@@ -70,6 +77,7 @@ public static class Credential
7077
public const string DevOAuthClientId = "gitHubDevClientId";
7178
public const string DevOAuthClientSecret = "gitHubDevClientSecret";
7279
public const string DevOAuthRedirectUri = "gitHubDevRedirectUri";
80+
public const string AccountFiltering = "githubAccountFiltering";
7381
}
7482
}
7583
}

0 commit comments

Comments
 (0)