Skip to content

Commit d70a146

Browse files
committed
github: filter GitHub.com accounts based on WWW-Auth headers
If we have been given a domain_hint in the WWW-Authenticate headers we should use that value to filter any existing accounts we have stored. The header format is: WWW-Authenticate: Basic realm="GitHub" [enterprise_hint="X"] [domain_hint="Y"] ..where X is the enterprise slug/name, and Y is the enterprise 'shortcode'. The shortcode is the suffix applied to GitHub.com accounts that are EMUs (Enterprise Managed Users). That is to say they are backed by an external IdP (Identity Provider). If we have not been given any WWW-Authenticate header (such as with older versions of Git), do not do any filtering. Likewise, if the remote is not GitHub.com (the only place EMUs mingle with other account types) then do no filtering.
1 parent 0521f2d commit d70a146

File tree

4 files changed

+276
-5
lines changed

4 files changed

+276
-5
lines changed

docs/configuration.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,27 @@ Defaults to undefined.
434434

435435
---
436436

437+
### credential.gitHubAccountFiltering
438+
439+
Enable or disable automatic account filtering for GitHub based on server hints
440+
when there are multiple available accounts. This setting is only applicable to
441+
GitHub.com with [Enterprise Managed Users][github-emu].
442+
443+
Value|Description
444+
-|-
445+
`true` _(default)_|Filter available accounts based on server hints.
446+
`false`|Show all available accounts.
447+
448+
#### Example
449+
450+
```shell
451+
git config --global credential.gitHubAccountFiltering "false"
452+
```
453+
454+
**Also see: [GCM_GITHUB_ACCOUNTFILTERING][gcm-github-accountfiltering]**
455+
456+
---
457+
437458
### credential.gitHubAuthModes
438459

439460
Override the available authentication modes presented during GitHub
@@ -863,6 +884,7 @@ Defaults to disabled.
863884
[gcm-credential-store]: environment.md#GCM_CREDENTIAL_STORE
864885
[gcm-debug]: environment.md#GCM_DEBUG
865886
[gcm-dpapi-store-path]: environment.md#GCM_DPAPI_STORE_PATH
887+
[gcm-github-accountfiltering]: environment.md#GCM_GITHUB_ACCOUNTFILTERING
866888
[gcm-github-authmodes]: environment.md#GCM_GITHUB_AUTHMODES
867889
[gcm-gitlab-authmodes]:environment.md#GCM_GITLAB_AUTHMODES
868890
[gcm-gui-prompt]: environment.md#GCM_GUI_PROMPT
@@ -877,6 +899,7 @@ Defaults to disabled.
877899
[gcm-trace]: environment.md#GCM_TRACE
878900
[gcm-trace-secrets]: environment.md#GCM_TRACE_SECRETS
879901
[gcm-trace-msauth]: environment.md#GCM_TRACE_MSAUTH
902+
[github-emu]: https://docs.github.com/en/enterprise-cloud@latest/admin/identity-and-access-management/using-enterprise-managed-users-for-iam/about-enterprise-managed-users
880903
[usage]: usage.md
881904
[git-config-http-proxy]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy
882905
[http-proxy]: netconfig.md#http-proxy

docs/environment.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,33 @@ Defaults to undefined.
525525

526526
---
527527

528+
### GCM_GITHUB_ACCOUNTFILTERING
529+
530+
Enable or disable automatic account filtering for GitHub based on server hints
531+
when there are multiple available accounts. This setting is only applicable to
532+
GitHub.com with [Enterprise Managed Users][github-emu].
533+
534+
Value|Description
535+
-|-
536+
`true` _(default)_|Filter available accounts based on server hints.
537+
`false`|Show all available accounts.
538+
539+
#### Windows
540+
541+
```batch
542+
SET GCM_GITHUB_ACCOUNTFILTERING=false
543+
```
544+
545+
#### macOS/Linux
546+
547+
```bash
548+
export GCM_GITHUB_ACCOUNTFILTERING=false
549+
```
550+
551+
**Also see: [credential.gitHubAccountFiltering][credential-githubaccountfiltering]**
552+
553+
---
554+
528555
### GCM_GITHUB_AUTHMODES
529556

530557
Override the available authentication modes presented during GitHub
@@ -964,6 +991,7 @@ Defaults to disabled.
964991
[credential-credentialstore]: configuration.md#credentialcredentialstore
965992
[credential-debug]: configuration.md#credentialdebug
966993
[credential-dpapi-store-path]: configuration.md#credentialdpapistorepath
994+
[credential-githubaccountfiltering]: configuration.md#credentialgitHubAccountFiltering
967995
[credential-githubauthmodes]: configuration.md#credentialgitHubAuthModes
968996
[credential-gitlabauthmodes]: configuration.md#credentialgitLabAuthModes
969997
[credential-guiprompt]: configuration.md#credentialguiprompt
@@ -991,6 +1019,7 @@ Defaults to disabled.
9911019
[git-cache-options]: https://git-scm.com/docs/git-credential-cache#_options
9921020
[git-credential-cache]: https://git-scm.com/docs/git-credential-cache
9931021
[git-httpproxy]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy
1022+
[github-emu]: https://docs.github.com/en/enterprise-cloud@latest/admin/identity-and-access-management/using-enterprise-managed-users-for-iam/about-enterprise-managed-users
9941023
[network-http-proxy]: netconfig.md#http-proxy
9951024
[libsecret]: https://wiki.gnome.org/Projects/Libsecret
9961025
[migration-guide]: migration.md#gcm_authority

src/shared/GitHub.Tests/GitHubHostProviderTests.cs

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ public void GitHubHostProvider_IsSupported(string protocol, string host, bool ex
6464
Assert.Equal(expected, provider.IsSupported(input));
6565
}
6666

67-
6867
[Theory]
6968
[InlineData("https", "github.com", "https://github.com")]
7069
[InlineData("https", "GitHub.Com", "https://github.com")]
@@ -151,6 +150,176 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes_WithMetadat
151150
Assert.Equal(expectedModes, actualModes);
152151
}
153152

153+
[Fact]
154+
public async Task GitHubHostProvider_GetCredentialAsync_NoCredentials_NoUserNoHeaders_PromptsUser()
155+
{
156+
var input = new InputArguments(
157+
new Dictionary<string, string>
158+
{
159+
["protocol"] = "https",
160+
["host"] = "github.com",
161+
}
162+
);
163+
164+
var newCredential = new GitCredential("alice", "password");
165+
166+
var context = new TestCommandContext();
167+
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
168+
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
169+
ghAuthMock.Setup(x => x.GetAuthenticationAsync(
170+
It.IsAny<Uri>(), It.IsAny<string>(), It.IsAny<AuthenticationModes>()))
171+
.ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.Pat, newCredential));
172+
173+
var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);
174+
175+
ICredential result = await provider.GetCredentialAsync(input);
176+
177+
Assert.Equal(result.Account, newCredential.Account);
178+
Assert.Equal(result.Password, newCredential.Password);
179+
ghAuthMock.Verify(x => x.GetAuthenticationAsync(
180+
new Uri("https://github.com"), null, It.IsAny<AuthenticationModes>()),
181+
Times.Once);
182+
}
183+
184+
[Fact]
185+
public async Task GitHubHostProvider_GetCredentialAsync_InputUser_ReturnsCredentialForUser()
186+
{
187+
var input = new InputArguments(
188+
new Dictionary<string, string>
189+
{
190+
["protocol"] = "https",
191+
["host"] = "github.com",
192+
["username"] = "alice"
193+
}
194+
);
195+
196+
var context = new TestCommandContext();
197+
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
198+
context.CredentialStore.Add("https://github.com", "bob", "secret123");
199+
200+
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
201+
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
202+
203+
var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);
204+
205+
ICredential result = await provider.GetCredentialAsync(input);
206+
207+
Assert.NotNull(result);
208+
Assert.Equal("alice", result.Account);
209+
Assert.Equal("letmein123", result.Password);
210+
}
211+
212+
[Fact]
213+
public async Task GitHubHostProvider_GetCredentialAsync_OneDomainAccount_ReturnsCredentialForRealmAccount()
214+
{
215+
var input = new InputArguments(
216+
new Dictionary<string, string>
217+
{
218+
["protocol"] = "https",
219+
["host"] = "github.com",
220+
["wwwauth"] = "Basic realm=\"GitHub\" domain_hint=\"contoso\"",
221+
}
222+
);
223+
224+
var context = new TestCommandContext();
225+
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
226+
context.CredentialStore.Add("https://github.com", "bob_contoso", "secret123");
227+
context.CredentialStore.Add("https://github.com", "test_fabrikam", "hidden_value");
228+
229+
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
230+
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
231+
232+
var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);
233+
234+
ICredential result = await provider.GetCredentialAsync(input);
235+
236+
Assert.NotNull(result);
237+
Assert.Equal("bob_contoso", result.Account);
238+
Assert.Equal("secret123", result.Password);
239+
}
240+
241+
[Fact]
242+
public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_PromptForAccountAndReturnCredentialForAccount()
243+
{
244+
var input = new InputArguments(
245+
new Dictionary<string, string>
246+
{
247+
["protocol"] = "https",
248+
["host"] = "github.com",
249+
["wwwauth"] = "Basic realm=\"GitHub\" domain_hint=\"contoso\"",
250+
}
251+
);
252+
253+
var context = new TestCommandContext();
254+
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
255+
context.CredentialStore.Add("https://github.com", "bob_contoso", "secret123");
256+
context.CredentialStore.Add("https://github.com", "john_contoso", "who_knows");
257+
258+
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
259+
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
260+
261+
ghAuthMock.Setup(x => x.SelectAccountAsync(It.IsAny<Uri>(), It.IsAny<IEnumerable<string>>()))
262+
.ReturnsAsync("john_contoso");
263+
264+
var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);
265+
266+
ICredential result = await provider.GetCredentialAsync(input);
267+
268+
Assert.NotNull(result);
269+
Assert.Equal("john_contoso", result.Account);
270+
Assert.Equal("who_knows", result.Password);
271+
272+
ghAuthMock.Verify(x => x.SelectAccountAsync(
273+
new Uri("https://github.com"), new[] { "bob_contoso", "john_contoso" }),
274+
Times.Once
275+
);
276+
}
277+
278+
[Fact]
279+
public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_PromptForAccountNewAccount()
280+
{
281+
var input = new InputArguments(
282+
new Dictionary<string, string>
283+
{
284+
["protocol"] = "https",
285+
["host"] = "github.com",
286+
["wwwauth"] = "Basic realm=\"GitHub\" domain_hint=\"contoso\"",
287+
}
288+
);
289+
290+
var newCredential = new GitCredential("alice", "password");
291+
292+
var context = new TestCommandContext();
293+
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
294+
context.CredentialStore.Add("https://github.com", "bob_contoso", "secret123");
295+
context.CredentialStore.Add("https://github.com", "john_contoso", "who_knows");
296+
297+
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
298+
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
299+
300+
ghAuthMock.Setup(x => x.SelectAccountAsync(It.IsAny<Uri>(), It.IsAny<IEnumerable<string>>()))
301+
.ReturnsAsync((string)null);
302+
303+
ghAuthMock.Setup(x => x.GetAuthenticationAsync(
304+
It.IsAny<Uri>(), It.IsAny<string>(), It.IsAny<AuthenticationModes>()))
305+
.ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.Pat, newCredential));
306+
307+
var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);
308+
309+
ICredential result = await provider.GetCredentialAsync(input);
310+
311+
Assert.Equal(newCredential.Account, result.Account);
312+
Assert.Equal(newCredential.Password, result.Password);
313+
314+
ghAuthMock.Verify(x => x.GetAuthenticationAsync(
315+
new Uri("https://github.com"), null, It.IsAny<AuthenticationModes>()),
316+
Times.Once);
317+
ghAuthMock.Verify(x => x.SelectAccountAsync(
318+
new Uri("https://github.com"), new[] { "bob_contoso", "john_contoso" }),
319+
Times.Once
320+
);
321+
}
322+
154323
[Fact]
155324
public async Task GitHubHostProvider_GenerateCredentialAsync_UnencryptedHttp_ThrowsException()
156325
{

src/shared/GitHub/GitHubHostProvider.cs

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ public async Task<ICredential> GetCredentialAsync(InputArguments input)
135135
// are multiple options.
136136
string userName = input.UserName;
137137
bool addAccount = false;
138+
bool filtered = false;
138139
if (string.IsNullOrWhiteSpace(userName))
139140
{
140141
IList<string> accounts = _context.CredentialStore.GetAccounts(service);
@@ -144,6 +145,8 @@ public async Task<ICredential> GetCredentialAsync(InputArguments input)
144145
_context.Trace.WriteLine($" {account}");
145146
}
146147

148+
filtered = FilterAccounts(remoteUri, input.WwwAuth, ref accounts);
149+
147150
switch (accounts.Count)
148151
{
149152
case 1:
@@ -159,15 +162,15 @@ public async Task<ICredential> GetCredentialAsync(InputArguments input)
159162
}
160163
}
161164

162-
// Always try and locate an existing credential in the OS credential store
163-
// unless we're being told to explicitly add a new account. If the account lookup
164-
// failed above we should still try to lookup an existing credential.
165+
// Always try and locate an existing credential in the OS credential store unless we're being
166+
// told to explicitly add a new account OR have specifically filtered out irrelevant accounts.
167+
// If the account lookup failed for another reason we should still try to lookup an existing credential.
165168
ICredential credential = null;
166169
if (addAccount)
167170
{
168171
_context.Trace.WriteLine("Adding a new account!");
169172
}
170-
else
173+
else if (!string.IsNullOrWhiteSpace(userName) || !filtered)
171174
{
172175
_context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={userName}...");
173176
credential = _context.CredentialStore.Get(service, userName);
@@ -190,6 +193,53 @@ public async Task<ICredential> GetCredentialAsync(InputArguments input)
190193
return credential;
191194
}
192195

196+
private bool FilterAccounts(Uri remoteUri, IEnumerable<string> wwwAuth, ref IList<string> accounts)
197+
{
198+
if (!IsGitHubDotCom(remoteUri))
199+
{
200+
_context.Trace.WriteLine("No account filtering outside of GitHub.com.");
201+
}
202+
203+
// Allow the user to disable account filtering until this feature stabilises.
204+
// Default to enabled.
205+
bool enableFiltering = !_context.Settings.TryGetSetting(
206+
GitHubConstants.EnvironmentVariables.AccountFiltering,
207+
Constants.GitConfiguration.Credential.SectionName,
208+
GitHubConstants.GitConfiguration.Credential.AccountFiltering,
209+
out string enableFilteringStr
210+
) || enableFilteringStr.ToBooleanyOrDefault(true);
211+
212+
if (!enableFiltering)
213+
{
214+
_context.Trace.WriteLine("Account filtering is disabled.");
215+
return false;
216+
}
217+
218+
_context.Trace.WriteLine("Account filtering is enabled.");
219+
220+
// If we have a WWW-Authenticate header then we can try and use any domain hint information
221+
// to filter the list of accounts to only those that are valid for that domain.
222+
// We only expect one challenge header to be returned, but if we're given more we just select the first.
223+
GitHubAuthChallenge authChallenge = GitHubAuthChallenge.FromHeaders(wwwAuth).FirstOrDefault();
224+
if (authChallenge is not null)
225+
{
226+
_context.Trace.WriteLine("Filtering based on WWW-Authenticate header information...");
227+
accounts = accounts.Where(authChallenge.IsDomainMember).ToList();
228+
229+
_context.Trace.WriteLine(string.IsNullOrWhiteSpace(authChallenge.Domain)
230+
? $"Matched {accounts.Count} accounts with public domain:"
231+
: $"Matched {accounts.Count} accounts with domain={authChallenge.Domain}:");
232+
foreach (string account in accounts)
233+
{
234+
_context.Trace.WriteLine($" {account}");
235+
}
236+
237+
return true;
238+
}
239+
240+
return false;
241+
}
242+
193243
public virtual Task StoreCredentialAsync(InputArguments input)
194244
{
195245
string service = GetServiceName(input);

0 commit comments

Comments
 (0)