Skip to content

Commit 29037f8

Browse files
authored
Merge pull request #15 from mjcheetham/github-provider
Add GitHub host provider (with basic TTY credential prompts)
2 parents 0e2e64e + 08ed7a5 commit 29037f8

14 files changed

+1205
-2
lines changed

Git-Credential-Manager.sln

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DD8B847B
2727
EndProject
2828
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestInfrastructure", "common\tests\TestInfrastructure\TestInfrastructure.csproj", "{5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}"
2929
EndProject
30+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub", "common\src\GitHub\GitHub.csproj", "{3C840B06-A595-4FD9-9A76-56CD45B14780}"
31+
EndProject
32+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.Tests", "common\tests\GitHub.Tests\GitHub.Tests.csproj", "{03B9B82B-7DCA-4554-97AB-C98FF18FB385}"
33+
EndProject
3034
Global
3135
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3236
Debug|Any CPU = Debug|Any CPU
@@ -83,6 +87,22 @@ Global
8387
{5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
8488
{5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
8589
{5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
90+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
91+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.Debug|Any CPU.Build.0 = Debug|Any CPU
92+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.Release|Any CPU.ActiveCfg = Release|Any CPU
93+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.Release|Any CPU.Build.0 = Release|Any CPU
94+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU
95+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
96+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
97+
{3C840B06-A595-4FD9-9A76-56CD45B14780}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
98+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
99+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.Debug|Any CPU.Build.0 = Debug|Any CPU
100+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.Release|Any CPU.ActiveCfg = Release|Any CPU
101+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.Release|Any CPU.Build.0 = Release|Any CPU
102+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU
103+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
104+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
105+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
86106
EndGlobalSection
87107
GlobalSection(SolutionProperties) = preSolution
88108
HideSolutionNode = FALSE
@@ -98,6 +118,8 @@ Global
98118
{1229E443-E66C-402F-8AA4-5AE6A207D3C7} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9}
99119
{DD8B847B-286B-4928-867B-E09C6C90DA1F} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9}
100120
{5A7D9E8B-C1D2-4C5C-BE98-648C41D1F8BD} = {4B305AC9-153F-4EA3-822F-3E5023BABAF1}
121+
{3C840B06-A595-4FD9-9A76-56CD45B14780} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
122+
{03B9B82B-7DCA-4554-97AB-C98FF18FB385} = {4B305AC9-153F-4EA3-822F-3E5023BABAF1}
101123
EndGlobalSection
102124
GlobalSection(ExtensibilityGlobals) = postSolution
103125
SolutionGuid = {0EF9FC65-E6BA-45D4-A455-262A9EA4366B}

common/src/Git-Credential-Manager/Git-Credential-Manager.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
</PropertyGroup>
1212

1313
<ItemGroup>
14+
<ProjectReference Include="..\GitHub\GitHub.csproj" />
1415
<ProjectReference Include="..\Microsoft.AzureRepos\Microsoft.AzureRepos.csproj" />
1516
<ProjectReference Include="..\Microsoft.Git.CredentialManager\Microsoft.Git.CredentialManager.csproj" />
1617
</ItemGroup>

common/src/Git-Credential-Manager/Program.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license.
33
using System;
4+
using GitHub;
45
using Microsoft.AzureRepos;
56

67
namespace Microsoft.Git.CredentialManager
@@ -15,6 +16,7 @@ public static void Main(string[] args)
1516
// Register all supported host providers
1617
app.ProviderRegistry.Register(
1718
new AzureReposHostProvider(context),
19+
new GitHubHostProvider(context),
1820
new GenericHostProvider(context)
1921
);
2022

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using Microsoft.Git.CredentialManager;
4+
5+
namespace GitHub
6+
{
7+
public struct AuthenticationResult
8+
{
9+
public AuthenticationResult(GitHubAuthenticationResultType type)
10+
{
11+
Type = type;
12+
Token = null;
13+
}
14+
15+
public AuthenticationResult(GitHubAuthenticationResultType type, GitCredential token)
16+
{
17+
Type = type;
18+
Token = token;
19+
}
20+
21+
public GitHubAuthenticationResultType Type { get; }
22+
23+
public GitCredential Token { get; }
24+
}
25+
26+
public enum GitHubAuthenticationResultType
27+
{
28+
Success,
29+
Failure,
30+
TwoFactorApp,
31+
TwoFactorSms,
32+
}
33+
}

common/src/GitHub/GitHub.csproj

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<RootNamespace>GitHub</RootNamespace>
6+
<AssemblyName>GitHub</AssemblyName>
7+
<IsTestProject>false</IsTestProject>
8+
<LangVersion>latest</LangVersion>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<ProjectReference Include="..\Microsoft.Git.CredentialManager\Microsoft.Git.CredentialManager.csproj" />
13+
</ItemGroup>
14+
15+
</Project>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using Microsoft.Git.CredentialManager;
5+
6+
namespace GitHub
7+
{
8+
public interface IGitHubAuthentication
9+
{
10+
bool TryGetCredentials(Uri targetUri, out string userName, out string password);
11+
12+
bool TryGetAuthenticationCode(Uri targetUri, bool isSms, out string authenticationCode);
13+
}
14+
15+
public class TtyGitHubPromptAuthentication : IGitHubAuthentication
16+
{
17+
private readonly ICommandContext _context;
18+
19+
public TtyGitHubPromptAuthentication(ICommandContext context)
20+
{
21+
EnsureArgument.NotNull(context, nameof(context));
22+
23+
_context = context;
24+
}
25+
26+
public bool TryGetCredentials(Uri targetUri, out string userName, out string password)
27+
{
28+
EnsureTerminalPromptsEnabled();
29+
30+
_context.StdError.WriteLine("Enter credentials for '{0}'...", targetUri);
31+
32+
userName = _context.Prompt("Username");
33+
password = _context.PromptSecret("Password");
34+
35+
return !string.IsNullOrWhiteSpace(userName) && !string.IsNullOrWhiteSpace(password);
36+
}
37+
38+
public bool TryGetAuthenticationCode(Uri targetUri, bool isSms, out string authenticationCode)
39+
{
40+
EnsureTerminalPromptsEnabled();
41+
42+
_context.StdError.WriteLine("Two-factor authentication is enabled and an authentication code is required.");
43+
44+
if (isSms)
45+
{
46+
_context.StdError.WriteLine("An SMS containing the authentication code has been sent to your registered device.");
47+
}
48+
else
49+
{
50+
_context.StdError.WriteLine("Use your registered authentication app to generate an authentication code.");
51+
}
52+
53+
authenticationCode = _context.Prompt("Authentication code");
54+
return !string.IsNullOrWhiteSpace(authenticationCode);
55+
}
56+
57+
private void EnsureTerminalPromptsEnabled()
58+
{
59+
if (_context.TryGetEnvironmentVariable(
60+
Constants.EnvironmentVariables.GitTerminalPrompts, out string envarPrompts)
61+
&& envarPrompts == "0")
62+
{
63+
_context.Trace.WriteLine($"{Constants.EnvironmentVariables.GitTerminalPrompts} is 0; terminal prompts have been disabled.");
64+
65+
throw new InvalidOperationException("Cannot show GitHub credential prompt because terminal prompts have been disabled.");
66+
}
67+
}
68+
}
69+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
namespace GitHub
4+
{
5+
public static class GitHubConstants
6+
{
7+
public const string GitHubBaseUrlHost = "github.com";
8+
public const string GistBaseUrlHost = "gist." + GitHubBaseUrlHost;
9+
10+
/// <summary>
11+
/// The GitHub required HTTP accepts header value
12+
/// </summary>
13+
public const string GitHubApiAcceptsHeaderValue = "application/vnd.github.v3+json";
14+
public const string GitHubOptHeader = "X-GitHub-OTP";
15+
16+
public static class TokenScopes
17+
{
18+
public const string Gist = "gist";
19+
public const string Repo = "repo";
20+
}
21+
}
22+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using System.Threading.Tasks;
5+
using Microsoft.Git.CredentialManager;
6+
7+
namespace GitHub
8+
{
9+
public class GitHubHostProvider : HostProvider
10+
{
11+
private static readonly string[] GitHubCredentialScopes =
12+
{
13+
GitHubConstants.TokenScopes.Gist,
14+
GitHubConstants.TokenScopes.Repo
15+
};
16+
17+
private readonly IGitHubRestApi _gitHubApi;
18+
private readonly IGitHubAuthentication _gitHubAuth;
19+
20+
public GitHubHostProvider(ICommandContext context)
21+
: this(context, new GitHubRestApi(context), new TtyGitHubPromptAuthentication(context)) { }
22+
23+
public GitHubHostProvider(ICommandContext context, IGitHubRestApi gitHubApi, IGitHubAuthentication gitHubAuth)
24+
: base(context)
25+
{
26+
EnsureArgument.NotNull(gitHubApi, nameof(gitHubApi));
27+
EnsureArgument.NotNull(gitHubAuth, nameof(gitHubAuth));
28+
29+
_gitHubApi = gitHubApi;
30+
_gitHubAuth = gitHubAuth;
31+
}
32+
33+
public override string Name => "GitHub";
34+
35+
public override bool IsSupported(InputArguments input)
36+
{
37+
// We do not support unencrypted HTTP communications to GitHub,
38+
// but we report `true` here for HTTP so that we can show a helpful
39+
// error message for the user in `CreateCredentialAsync`.
40+
return input != null &&
41+
(StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") ||
42+
StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) &&
43+
(StringComparer.OrdinalIgnoreCase.Equals(input.Host, GitHubConstants.GitHubBaseUrlHost) ||
44+
StringComparer.OrdinalIgnoreCase.Equals(input.Host, GitHubConstants.GistBaseUrlHost));
45+
}
46+
47+
public override string GetCredentialKey(InputArguments input)
48+
{
49+
string url = GetTargetUri(input).AbsoluteUri;
50+
51+
// Trim trailing slash
52+
if (url.EndsWith("/"))
53+
{
54+
return url.Substring(0, url.Length - 1);
55+
}
56+
57+
return url;
58+
}
59+
60+
public override async Task<GitCredential> CreateCredentialAsync(InputArguments input)
61+
{
62+
// We should not allow unencrypted communication and should inform the user
63+
if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http"))
64+
{
65+
throw new Exception("Unencrypted HTTP is not supported for GitHub. Ensure the repository remote URL is using HTTPS.");
66+
}
67+
68+
Uri targetUri = GetTargetUri(input);
69+
70+
if (_gitHubAuth.TryGetCredentials(targetUri, out string username, out string password))
71+
{
72+
AuthenticationResult result = await _gitHubApi.AcquireTokenAsync(
73+
targetUri, username, password, null, GitHubCredentialScopes);
74+
75+
if (result.Type == GitHubAuthenticationResultType.Success)
76+
{
77+
Context.Trace.WriteLine($"Token acquisition for '{targetUri}' succeeded");
78+
79+
return result.Token;
80+
}
81+
82+
if (result.Type == GitHubAuthenticationResultType.TwoFactorApp ||
83+
result.Type == GitHubAuthenticationResultType.TwoFactorSms)
84+
{
85+
bool isSms = result.Type == GitHubAuthenticationResultType.TwoFactorSms;
86+
87+
if (_gitHubAuth.TryGetAuthenticationCode(targetUri, isSms, out string authenticationCode))
88+
{
89+
result = await _gitHubApi.AcquireTokenAsync(
90+
targetUri, username, password, authenticationCode, GitHubCredentialScopes);
91+
92+
if (result.Type == GitHubAuthenticationResultType.Success)
93+
{
94+
Context.Trace.WriteLine($"Token acquisition for '{targetUri}' succeeded.");
95+
96+
return result.Token;
97+
}
98+
}
99+
}
100+
}
101+
102+
throw new Exception($"Interactive logon for '{targetUri}' failed.");
103+
}
104+
105+
protected override void Dispose(bool disposing)
106+
{
107+
if (disposing)
108+
{
109+
_gitHubApi.Dispose();
110+
}
111+
112+
base.Dispose(disposing);
113+
}
114+
115+
#region Private Methods
116+
117+
private static Uri NormalizeUri(Uri targetUri)
118+
{
119+
if (targetUri is null)
120+
throw new ArgumentNullException(nameof(targetUri));
121+
122+
// Special case for gist.github.com which are git backed repositories under the hood.
123+
// Credentials for these repositories are the same as the one stored with "github.com"
124+
if (targetUri.DnsSafeHost.Equals(GitHubConstants.GistBaseUrlHost, StringComparison.OrdinalIgnoreCase))
125+
{
126+
return new Uri("https://" + GitHubConstants.GitHubBaseUrlHost);
127+
}
128+
129+
return targetUri;
130+
}
131+
132+
private static Uri GetTargetUri(InputArguments input)
133+
{
134+
Uri uri = new UriBuilder
135+
{
136+
Scheme = input.Protocol,
137+
Host = input.Host,
138+
}.Uri;
139+
140+
return NormalizeUri(uri);
141+
}
142+
143+
#endregion
144+
}
145+
}

0 commit comments

Comments
 (0)