Skip to content

Commit

Permalink
github: filter github.com accounts based on WWW-Auth headers
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
mjcheetham committed Jun 22, 2023
1 parent 0521f2d commit d70a146
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 5 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
171 changes: 170 additions & 1 deletion src/shared/GitHub.Tests/GitHubHostProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ public void GitHubHostProvider_IsSupported(string protocol, string host, bool ex
Assert.Equal(expected, provider.IsSupported(input));
}


[Theory]
[InlineData("https", "github.com", "https://github.com")]
[InlineData("https", "github.com", "https://github.com")]
Expand Down Expand Up @@ -151,6 +150,176 @@ public async Task GitHubHostProvider_GetSupportedAuthenticationModes_WithMetadat
Assert.Equal(expectedModes, actualModes);
}

[Fact]
public async Task GitHubHostProvider_GetCredentialAsync_NoCredentials_NoUserNoHeaders_PromptsUser()
{
var input = new InputArguments(
new Dictionary<string, string>
{
["protocol"] = "https",
["host"] = "github.com",
}
);

var newCredential = new GitCredential("alice", "password");

var context = new TestCommandContext();
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
ghAuthMock.Setup(x => x.GetAuthenticationAsync(
It.IsAny<Uri>(), It.IsAny<string>(), It.IsAny<AuthenticationModes>()))
.ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.Pat, newCredential));

var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);

ICredential result = await provider.GetCredentialAsync(input);

Assert.Equal(result.Account, newCredential.Account);
Assert.Equal(result.Password, newCredential.Password);
ghAuthMock.Verify(x => x.GetAuthenticationAsync(
new Uri("https://github.com"), null, It.IsAny<AuthenticationModes>()),
Times.Once);
}

[Fact]
public async Task GitHubHostProvider_GetCredentialAsync_InputUser_ReturnsCredentialForUser()
{
var input = new InputArguments(
new Dictionary<string, string>
{
["protocol"] = "https",
["host"] = "github.com",
["username"] = "alice"
}
);

var context = new TestCommandContext();
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
context.CredentialStore.Add("https://github.com", "bob", "secret123");

var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);

var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);

ICredential result = await provider.GetCredentialAsync(input);

Assert.NotNull(result);
Assert.Equal("alice", result.Account);
Assert.Equal("letmein123", result.Password);
}

[Fact]
public async Task GitHubHostProvider_GetCredentialAsync_OneDomainAccount_ReturnsCredentialForRealmAccount()
{
var input = new InputArguments(
new Dictionary<string, string>
{
["protocol"] = "https",
["host"] = "github.com",
["wwwauth"] = "Basic realm=\"GitHub\" domain_hint=\"contoso\"",
}
);

var context = new TestCommandContext();
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
context.CredentialStore.Add("https://github.com", "bob_contoso", "secret123");
context.CredentialStore.Add("https://github.com", "test_fabrikam", "hidden_value");

var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);

var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);

ICredential result = await provider.GetCredentialAsync(input);

Assert.NotNull(result);
Assert.Equal("bob_contoso", result.Account);
Assert.Equal("secret123", result.Password);
}

[Fact]
public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_PromptForAccountAndReturnCredentialForAccount()
{
var input = new InputArguments(
new Dictionary<string, string>
{
["protocol"] = "https",
["host"] = "github.com",
["wwwauth"] = "Basic realm=\"GitHub\" domain_hint=\"contoso\"",
}
);

var context = new TestCommandContext();
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
context.CredentialStore.Add("https://github.com", "bob_contoso", "secret123");
context.CredentialStore.Add("https://github.com", "john_contoso", "who_knows");

var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);

ghAuthMock.Setup(x => x.SelectAccountAsync(It.IsAny<Uri>(), It.IsAny<IEnumerable<string>>()))
.ReturnsAsync("john_contoso");

var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);

ICredential result = await provider.GetCredentialAsync(input);

Assert.NotNull(result);
Assert.Equal("john_contoso", result.Account);
Assert.Equal("who_knows", result.Password);

ghAuthMock.Verify(x => x.SelectAccountAsync(
new Uri("https://github.com"), new[] { "bob_contoso", "john_contoso" }),
Times.Once
);
}

[Fact]
public async Task GitHubHostProvider_GetCredentialAsync_MultipleDomainAccounts_PromptForAccountNewAccount()
{
var input = new InputArguments(
new Dictionary<string, string>
{
["protocol"] = "https",
["host"] = "github.com",
["wwwauth"] = "Basic realm=\"GitHub\" domain_hint=\"contoso\"",
}
);

var newCredential = new GitCredential("alice", "password");

var context = new TestCommandContext();
context.CredentialStore.Add("https://github.com", "alice", "letmein123");
context.CredentialStore.Add("https://github.com", "bob_contoso", "secret123");
context.CredentialStore.Add("https://github.com", "john_contoso", "who_knows");

var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);

ghAuthMock.Setup(x => x.SelectAccountAsync(It.IsAny<Uri>(), It.IsAny<IEnumerable<string>>()))
.ReturnsAsync((string)null);

ghAuthMock.Setup(x => x.GetAuthenticationAsync(
It.IsAny<Uri>(), It.IsAny<string>(), It.IsAny<AuthenticationModes>()))
.ReturnsAsync(new AuthenticationPromptResult(AuthenticationModes.Pat, newCredential));

var provider = new GitHubHostProvider(context, ghApiMock.Object, ghAuthMock.Object);

ICredential result = await provider.GetCredentialAsync(input);

Assert.Equal(newCredential.Account, result.Account);
Assert.Equal(newCredential.Password, result.Password);

ghAuthMock.Verify(x => x.GetAuthenticationAsync(
new Uri("https://github.com"), null, It.IsAny<AuthenticationModes>()),
Times.Once);
ghAuthMock.Verify(x => x.SelectAccountAsync(
new Uri("https://github.com"), new[] { "bob_contoso", "john_contoso" }),
Times.Once
);
}

[Fact]
public async Task GitHubHostProvider_GenerateCredentialAsync_UnencryptedHttp_ThrowsException()
{
Expand Down
58 changes: 54 additions & 4 deletions src/shared/GitHub/GitHubHostProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ public async Task<ICredential> GetCredentialAsync(InputArguments input)
// are multiple options.
string userName = input.UserName;
bool addAccount = false;
bool filtered = false;
if (string.IsNullOrWhiteSpace(userName))
{
IList<string> accounts = _context.CredentialStore.GetAccounts(service);
Expand All @@ -144,6 +145,8 @@ public async Task<ICredential> GetCredentialAsync(InputArguments input)
_context.Trace.WriteLine($" {account}");
}

filtered = FilterAccounts(remoteUri, input.WwwAuth, ref accounts);

switch (accounts.Count)
{
case 1:
Expand All @@ -159,15 +162,15 @@ public async Task<ICredential> GetCredentialAsync(InputArguments input)
}
}

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

private bool FilterAccounts(Uri remoteUri, IEnumerable<string> wwwAuth, ref IList<string> accounts)
{
if (!IsGitHubDotCom(remoteUri))
{
_context.Trace.WriteLine("No account filtering outside of github.com.");
}

// Allow the user to disable account filtering until this feature stabilises.
// Default to enabled.
bool enableFiltering = !_context.Settings.TryGetSetting(
GitHubConstants.EnvironmentVariables.AccountFiltering,
Constants.GitConfiguration.Credential.SectionName,
GitHubConstants.GitConfiguration.Credential.AccountFiltering,
out string enableFilteringStr
) || enableFilteringStr.ToBooleanyOrDefault(true);

if (!enableFiltering)
{
_context.Trace.WriteLine("Account filtering is disabled.");
return false;
}

_context.Trace.WriteLine("Account filtering is enabled.");

// If we have a WWW-Authenticate header then we can try and use any domain hint information
// to filter the list of accounts to only those that are valid for that domain.
// We only expect one challenge header to be returned, but if we're given more we just select the first.
GitHubAuthChallenge authChallenge = GitHubAuthChallenge.FromHeaders(wwwAuth).FirstOrDefault();
if (authChallenge is not null)
{
_context.Trace.WriteLine("Filtering based on WWW-Authenticate header information...");
accounts = accounts.Where(authChallenge.IsDomainMember).ToList();

_context.Trace.WriteLine(string.IsNullOrWhiteSpace(authChallenge.Domain)
? $"Matched {accounts.Count} accounts with public domain:"
: $"Matched {accounts.Count} accounts with domain={authChallenge.Domain}:");
foreach (string account in accounts)
{
_context.Trace.WriteLine($" {account}");
}

return true;
}

return false;
}

public virtual Task StoreCredentialAsync(InputArguments input)
{
string service = GetServiceName(input);
Expand Down

0 comments on commit d70a146

Please sign in to comment.