Skip to content

Commit a211bab

Browse files
authored
Add support for GitHub enterprise-managed user accounts (#1190)
Add support for GitHub [enterprise-manage users (EMU)](https://docs.github.com/en/enterprise-cloud@latest/admin/identity-and-access-management/using-enterprise-managed-users-for-iam/about-enterprise-managed-users) to the GitHub host provider. Accounts in an 'EMU' enterprise/business are siloed from the regular, public GitHub.com accounts. EMU accounts are identified by the `_shortcode` suffix, where the `shortcode` is a moniker for the enterprise/business, for example `alice_contoso`. When asked to recall credentials for the GitHub.com host we now attempt to filter stored accounts by the `shortcode`, given information provided in `WWW-Authenticate` headers from upcoming versions of Git that support these headers (as of gitgitgadget/git@92c56da). The format of the header is: ``` WWW-Authenticate: Basic realm="GitHub" [domain_hint="X"] [enterprise_hint="Y"] ``` ..where `X` is the shortcode, and `Y` is the enterprise name. If multiple accounts are available for the given 'domain' then we present an account selection prompt. Users can avoid this prompt in the case of multiple user accounts by specifying the desired account in the remote URL (e.g. `https://[email protected]/mona/test` to always use the `alice` account). Note that GitHub.com does not yet return such `WWW-Authenticate` headers, except always `Basic realm="GitHub"`, so this may be subject to fixes later. In the case of `realm="GitHub"`, i.e., public accounts, there is no change. ### Testing To test the new behaviour before GitHub.com returns such headers, it's possible to fake the server response using [`mitmproxy`](https://mitmproxy.org) and the following script: ```python """Add an HTTP header to each response.""" class AddHeader: # initialize a dict with shortcodes and paths def __init__(self): org1 = ("domain1", "enterprise1") org2 = ("domain2", "enterprise2") self.orgMap = { "org1" : enterprise1, "org2" : enterprise1, "org3" : enterprise2, } def response(self, flow): if flow.response.status_code == 401: # lookup the correct shortcode based on the org org = flow.request.path.split("/")[1] if org not in self.orgMap: return domain_hint = self.orgMap[org][0] enterprise_hint = self.orgMap[org][1] # build the header header = "Basic realm=\"GitHub\" enterprise_hint=\"" + enterprise_hint + "\" domain_hint=\"" + domain_hint + "\"" # set the header flow.response.headers["WWW-Authenticate"] = header addons = [ AddHeader() ] ``` Replace `orgN` with the org names that are backed by an EMU Enterprise, and fill `domainN` for the shortcode, and `enterpriseN` for the enterprise slug/name. Configure Git to use the proxy and run `mitmproxy` with the `--scripts` argument: ```shell git config --global http.proxy 'http://127.0.0.1:8080' mitmproxy --scripts <SCRIPT> ``` Now all Git interactions that touch `orgN` will include the `domain_hint` and `enterprise_hint`s as defined. I use these two helpful aliases to quickly add and remove the local proxy from Git's config: ```shell [alias] mitm = "!f(){ git config --global http.proxy 'http://127.0.0.1:8080'; }; f" unmitm = "!f(){ git config --global --unset http.proxy; }; f" ```
2 parents d045abe + d70a146 commit a211bab

File tree

7 files changed

+558
-6
lines changed

7 files changed

+558
-6
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
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+
}

0 commit comments

Comments
 (0)