Skip to content

Commit

Permalink
Add support for GitHub enterprise-managed user accounts (#1190)
Browse files Browse the repository at this point in the history
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://alice@github.com/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"
```
  • Loading branch information
mjcheetham committed Jun 23, 2023
2 parents d045abe + d70a146 commit a211bab
Show file tree
Hide file tree
Showing 7 changed files with 558 additions and 6 deletions.
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,27 @@ Defaults to undefined.

---

### credential.gitHubAccountFiltering

Enable or disable automatic account filtering for GitHub based on server hints
when there are multiple available accounts. This setting is only applicable to
GitHub.com with [Enterprise Managed Users][github-emu].

Value|Description
-|-
`true` _(default)_|Filter available accounts based on server hints.
`false`|Show all available accounts.

#### Example

```shell
git config --global credential.gitHubAccountFiltering "false"
```

**Also see: [GCM_GITHUB_ACCOUNTFILTERING][gcm-github-accountfiltering]**

---

### credential.gitHubAuthModes

Override the available authentication modes presented during GitHub
Expand Down Expand Up @@ -863,6 +884,7 @@ Defaults to disabled.
[gcm-credential-store]: environment.md#GCM_CREDENTIAL_STORE
[gcm-debug]: environment.md#GCM_DEBUG
[gcm-dpapi-store-path]: environment.md#GCM_DPAPI_STORE_PATH
[gcm-github-accountfiltering]: environment.md#GCM_GITHUB_ACCOUNTFILTERING
[gcm-github-authmodes]: environment.md#GCM_GITHUB_AUTHMODES
[gcm-gitlab-authmodes]:environment.md#GCM_GITLAB_AUTHMODES
[gcm-gui-prompt]: environment.md#GCM_GUI_PROMPT
Expand All @@ -877,6 +899,7 @@ Defaults to disabled.
[gcm-trace]: environment.md#GCM_TRACE
[gcm-trace-secrets]: environment.md#GCM_TRACE_SECRETS
[gcm-trace-msauth]: environment.md#GCM_TRACE_MSAUTH
[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
[usage]: usage.md
[git-config-http-proxy]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy
[http-proxy]: netconfig.md#http-proxy
Expand Down
29 changes: 29 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,33 @@ Defaults to undefined.

---

### GCM_GITHUB_ACCOUNTFILTERING

Enable or disable automatic account filtering for GitHub based on server hints
when there are multiple available accounts. This setting is only applicable to
GitHub.com with [Enterprise Managed Users][github-emu].

Value|Description
-|-
`true` _(default)_|Filter available accounts based on server hints.
`false`|Show all available accounts.

#### Windows

```batch
SET GCM_GITHUB_ACCOUNTFILTERING=false
```

#### macOS/Linux

```bash
export GCM_GITHUB_ACCOUNTFILTERING=false
```

**Also see: [credential.gitHubAccountFiltering][credential-githubaccountfiltering]**

---

### GCM_GITHUB_AUTHMODES

Override the available authentication modes presented during GitHub
Expand Down Expand Up @@ -964,6 +991,7 @@ Defaults to disabled.
[credential-credentialstore]: configuration.md#credentialcredentialstore
[credential-debug]: configuration.md#credentialdebug
[credential-dpapi-store-path]: configuration.md#credentialdpapistorepath
[credential-githubaccountfiltering]: configuration.md#credentialgitHubAccountFiltering
[credential-githubauthmodes]: configuration.md#credentialgitHubAuthModes
[credential-gitlabauthmodes]: configuration.md#credentialgitLabAuthModes
[credential-guiprompt]: configuration.md#credentialguiprompt
Expand Down Expand Up @@ -991,6 +1019,7 @@ Defaults to disabled.
[git-cache-options]: https://git-scm.com/docs/git-credential-cache#_options
[git-credential-cache]: https://git-scm.com/docs/git-credential-cache
[git-httpproxy]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-httpproxy
[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
[network-http-proxy]: netconfig.md#http-proxy
[libsecret]: https://wiki.gnome.org/Projects/Libsecret
[migration-guide]: migration.md#gcm_authority
Expand Down
153 changes: 153 additions & 0 deletions src/shared/GitHub.Tests/GitHubAuthChallengeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using Xunit;

namespace GitHub.Tests;

public class GitHubAuthChallengeTests
{
[Fact]
public void GitHubAuthChallenge_FromHeaders_CaseInsensitive()
{
var headers = new[]
{
"BASIC REALM=\"GITHUB\"",
"basic realm=\"github\"",
"bAsIc ReAlM=\"gItHuB\"",
};

IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
Assert.Equal(3, challenges.Count);

foreach (var challenge in challenges)
{
Assert.Null(challenge.Domain);
Assert.Null(challenge.Enterprise);
}
}

[Fact]
public void GitHubAuthChallenge_FromHeaders_MultipleRealms_ReturnsGitHubOnly()
{
var headers = new[]
{
"Basic realm=\"contoso\"",
"Basic realm=\"GitHub\"",
"Basic realm=\"fabrikam\"",
};

IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
Assert.Single(challenges);

Assert.Null(challenges[0].Domain);
Assert.Null(challenges[0].Enterprise);
}

[Fact]
public void GitHubAuthChallenge_FromHeaders_NoMatchingRealms_ReturnsEmpty()
{
var headers = new[]
{
"Basic realm=\"contoso\"",
"Basic realm=\"fabrikam\"",
"Basic realm=\"example\"",
};

IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
Assert.Empty(challenges);
}

[Theory]
[InlineData("Basic realm=\"GitHub\" enterprise_hint=\"contoso-corp\" domain_hint=\"contoso\"", "contoso", "contoso-corp")]
[InlineData("Basic realm=\"GitHub\" domain_hint=\"contoso\"", "contoso", null)]
[InlineData("Basic realm=\"GitHub\" enterprise_hint=\"contoso-corp\"", null, "contoso-corp")]
[InlineData("Basic realm=\"GitHub\" domain_hint=\"fab\" enterprise_hint=\"fabirkamopensource\"", "fab", "fabirkamopensource")]
[InlineData("Basic enterprise_hint=\"iana\" realm=\"GitHub\" domain_hint=\"example\"", "example", "iana")]
[InlineData("Basic domain_hint=\"test\" enterprise_hint=\"test-inc\" realm=\"GitHub\"", "test", "test-inc")]
public void GitHubAuthChallenge_FromHeaders_Hints_ReturnsWithHints(string header, string domain, string enterprise)
{
IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(new[] { header });
Assert.Single(challenges);

Assert.Equal(domain, challenges[0].Domain);
Assert.Equal(enterprise, challenges[0].Enterprise);
}

[Fact]
public void GitHubAuthChallenge_FromHeaders_EmptyHeaders_ReturnsEmpty()
{
string[] headers = Array.Empty<string>();
IList<GitHubAuthChallenge> challenges = GitHubAuthChallenge.FromHeaders(headers);
Assert.Empty(challenges);
}

[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("alice", true)]
[InlineData("alice_contoso", false)]
[InlineData("alice_CONTOSO", false)]
[InlineData("alice_contoso_alt", false)]
[InlineData("pj_nitin", true)]
[InlineData("up_the_irons", true)]
public void GitHubAuthChallenge_IsDomainMember_NoHint(string userName, bool expected)
{
var challenge = new GitHubAuthChallenge();
Assert.Equal(expected, challenge.IsDomainMember(userName));
}

[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("alice", false)]
[InlineData("alice_contoso", true)]
[InlineData("alice_CONTOSO", true)]
[InlineData("alice_contoso_alt", false)]
[InlineData("pj_nitin", false)]
[InlineData("up_the_irons", false)]
public void GitHubAuthChallenge_IsDomainMember_DomainHint(string userName, bool expected)
{
var realm = new GitHubAuthChallenge("contoso", "contoso-corp");
Assert.Equal(expected, realm.IsDomainMember(userName));
}

[Fact]
public void GitHubAuthChallenge_Equals_Null_ReturnsFalse()
{
var challenge = new GitHubAuthChallenge("contoso", "contoso-corp");
Assert.False(challenge.Equals(null));
}

[Fact]
public void GitHubAuthChallenge_Equals_SameInstance_ReturnsTrue()
{
var challenge = new GitHubAuthChallenge("contoso", "contoso-corp");
Assert.True(challenge.Equals(challenge));
}

[Fact]
public void GitHubAuthChallenge_Equals_DifferentInstance_ReturnsTrue()
{
var challenge1 = new GitHubAuthChallenge("contoso", "constoso-corp");
var challenge2 = new GitHubAuthChallenge("contoso", "constoso-corp");
Assert.True(challenge1.Equals(challenge2));
}

[Fact]
public void GitHubAuthChallenge_Equals_DifferentCase_ReturnsTrue()
{
var challenge1 = new GitHubAuthChallenge("contoso", "contoso-corp");
var challenge2 = new GitHubAuthChallenge("CONTOSO", "CONTOSO-CORP");
Assert.True(challenge1.Equals(challenge2));
}

[Fact]
public void GitHubAuthChallenge_Equals_DifferentShortCode_ReturnsFalse()
{
var challenge1 = new GitHubAuthChallenge("contoso", "constoso-corp");
var challenge2 = new GitHubAuthChallenge("fab", "fabrikamopensource");
Assert.False(challenge1.Equals(challenge2));
}
}
Loading

0 comments on commit a211bab

Please sign in to comment.