Skip to content

Commit b001322

Browse files
authored
Merge pull request #236 from mjcheetham/githubpatmode
Add explicit PAT authentication mode for the GitHub Provider
2 parents f9f2ad9 + 6fadc4b commit b001322

23 files changed

+429
-150
lines changed

src/shared/Atlassian.Bitbucket/BitbucketAuthentication.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public async Task<ICredential> GetBasicCredentialsAsync(Uri targetUri, string us
5050
var cmdArgs = new StringBuilder("userpass");
5151
if (!string.IsNullOrWhiteSpace(userName))
5252
{
53-
cmdArgs.AppendFormat(" --username {0}", userName);
53+
cmdArgs.AppendFormat(" --username {0}", QuoteCmdArg(userName));
5454
}
5555

5656
IDictionary<string, string> output = await InvokeHelperAsync(helperPath, cmdArgs.ToString());

src/shared/GitHub.Tests/GitHubHostProviderTests.cs

+4-2
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,8 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Basic_1FAOnly_Retur
221221

222222
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
223223
ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny<AuthenticationModes>()))
224-
.ReturnsAsync(new AuthenticationPromptResult(new GitCredential(expectedUserName, expectedPassword)));
224+
.ReturnsAsync(new AuthenticationPromptResult(
225+
AuthenticationModes.Basic, new GitCredential(expectedUserName, expectedPassword)));
225226

226227
var ghApiMock = new Mock<IGitHubRestApi>(MockBehavior.Strict);
227228
ghApiMock.Setup(x => x.CreatePersonalAccessTokenAsync(expectedTargetUri, expectedUserName, expectedPassword, null, It.IsAny<IEnumerable<string>>()))
@@ -270,7 +271,8 @@ public async Task GitHubHostProvider_GenerateCredentialAsync_Basic_2FARequired_R
270271

271272
var ghAuthMock = new Mock<IGitHubAuthentication>(MockBehavior.Strict);
272273
ghAuthMock.Setup(x => x.GetAuthenticationAsync(expectedTargetUri, null, It.IsAny<AuthenticationModes>()))
273-
.ReturnsAsync(new AuthenticationPromptResult(new GitCredential(expectedUserName, expectedPassword)));
274+
.ReturnsAsync(new AuthenticationPromptResult(
275+
AuthenticationModes.Basic, new GitCredential(expectedUserName, expectedPassword)));
274276
ghAuthMock.Setup(x => x.GetTwoFactorCodeAsync(expectedTargetUri, false))
275277
.ReturnsAsync(expectedAuthCode);
276278

src/shared/GitHub/GitHubAuthentication.cs

+61-27
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ public AuthenticationPromptResult(AuthenticationModes mode)
2828
AuthenticationMode = mode;
2929
}
3030

31-
public AuthenticationPromptResult(ICredential basicCredential)
32-
: this(AuthenticationModes.Basic)
31+
public AuthenticationPromptResult(AuthenticationModes mode, ICredential credential)
32+
: this(mode)
3333
{
34-
BasicCredential = basicCredential;
34+
Credential = credential;
3535
}
3636

3737
public AuthenticationModes AuthenticationMode { get; }
3838

39-
public ICredential BasicCredential { get; set; }
39+
public ICredential Credential { get; set; }
4040
}
4141

4242
[Flags]
@@ -45,6 +45,9 @@ public enum AuthenticationModes
4545
None = 0,
4646
Basic = 1,
4747
OAuth = 1 << 1,
48+
Pat = 1 << 2,
49+
50+
All = Basic | OAuth | Pat
4851
}
4952

5053
public class GitHubAuthentication : AuthenticationBase, IGitHubAuthentication
@@ -63,16 +66,24 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
6366

6467
if (modes == AuthenticationModes.None)
6568
{
66-
throw new ArgumentException($"Must specify at least one {nameof(AuthenticationModes)}", nameof(modes));
69+
throw new ArgumentException(@$"Must specify at least one {nameof(AuthenticationModes)}", nameof(modes));
6770
}
6871

6972
if (TryFindHelperExecutablePath(out string helperPath))
7073
{
7174
var promptArgs = new StringBuilder("prompt");
72-
if ((modes & AuthenticationModes.Basic) != 0) promptArgs.Append(" --basic");
73-
if ((modes & AuthenticationModes.OAuth) != 0) promptArgs.Append(" --oauth");
74-
if (!GitHubHostProvider.IsGitHubDotCom(targetUri)) promptArgs.AppendFormat(" --enterprise-url {0}", targetUri);
75-
if (!string.IsNullOrWhiteSpace(userName)) promptArgs.AppendFormat(" --username {0}", userName);
75+
if (modes == AuthenticationModes.All)
76+
{
77+
promptArgs.Append(" --all");
78+
}
79+
else
80+
{
81+
if ((modes & AuthenticationModes.Basic) != 0) promptArgs.Append(" --basic");
82+
if ((modes & AuthenticationModes.OAuth) != 0) promptArgs.Append(" --oauth");
83+
if ((modes & AuthenticationModes.Pat) != 0) promptArgs.Append(" --pat");
84+
}
85+
if (!GitHubHostProvider.IsGitHubDotCom(targetUri)) promptArgs.AppendFormat(" --enterprise-url {0}", QuoteCmdArg(targetUri.ToString()));
86+
if (!string.IsNullOrWhiteSpace(userName)) promptArgs.AppendFormat(" --username {0}", QuoteCmdArg(userName));
7687

7788
IDictionary<string, string> resultDict = await InvokeHelperAsync(helperPath, promptArgs.ToString(), null);
7889

@@ -83,6 +94,15 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
8394

8495
switch (responseMode.ToLowerInvariant())
8596
{
97+
case "pat":
98+
if (!resultDict.TryGetValue("pat", out string pat))
99+
{
100+
throw new Exception("Missing 'pat' in response");
101+
}
102+
103+
return new AuthenticationPromptResult(
104+
AuthenticationModes.Pat, new GitCredential(userName, pat));
105+
86106
case "oauth":
87107
return new AuthenticationPromptResult(AuthenticationModes.OAuth);
88108

@@ -97,7 +117,8 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
97117
throw new Exception("Missing 'password' in response");
98118
}
99119

100-
return new AuthenticationPromptResult(new GitCredential(userName, password));
120+
return new AuthenticationPromptResult(
121+
AuthenticationModes.Basic, new GitCredential(userName, password));
101122

102123
default:
103124
throw new Exception($"Unknown mode value in response '{responseMode}'");
@@ -109,21 +130,6 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
109130

110131
switch (modes)
111132
{
112-
case AuthenticationModes.Basic | AuthenticationModes.OAuth:
113-
var menuTitle = $"Select an authentication method for '{targetUri}'";
114-
var menu = new TerminalMenu(Context.Terminal, menuTitle)
115-
{
116-
new TerminalMenuItem(1, "Web browser", isDefault: true),
117-
new TerminalMenuItem(2, "Username/password")
118-
};
119-
120-
int option = menu.Show();
121-
122-
if (option == 1) goto case AuthenticationModes.OAuth;
123-
if (option == 2) goto case AuthenticationModes.Basic;
124-
125-
throw new Exception();
126-
127133
case AuthenticationModes.Basic:
128134
Context.Terminal.WriteLine("Enter GitHub credentials for '{0}'...", targetUri);
129135

@@ -138,13 +144,41 @@ public async Task<AuthenticationPromptResult> GetAuthenticationAsync(Uri targetU
138144

139145
string password = Context.Terminal.PromptSecret("Password");
140146

141-
return new AuthenticationPromptResult(new GitCredential(userName, password));
147+
return new AuthenticationPromptResult(
148+
AuthenticationModes.Basic, new GitCredential(userName, password));
142149

143150
case AuthenticationModes.OAuth:
144151
return new AuthenticationPromptResult(AuthenticationModes.OAuth);
145152

153+
case AuthenticationModes.Pat:
154+
Context.Terminal.WriteLine("Enter GitHub personal access token for '{0}'...", targetUri);
155+
string pat = Context.Terminal.PromptSecret("Token");
156+
return new AuthenticationPromptResult(
157+
AuthenticationModes.Pat, new GitCredential(userName, pat));
158+
159+
case AuthenticationModes.None:
160+
throw new ArgumentOutOfRangeException(nameof(modes), @$"At least one {nameof(AuthenticationModes)} must be supplied");
161+
146162
default:
147-
throw new ArgumentOutOfRangeException(nameof(modes), $"Unknown {nameof(AuthenticationModes)} value");
163+
var menuTitle = $"Select an authentication method for '{targetUri}'";
164+
var menu = new TerminalMenu(Context.Terminal, menuTitle);
165+
166+
TerminalMenuItem oauthItem = null;
167+
TerminalMenuItem basicItem = null;
168+
TerminalMenuItem patItem = null;
169+
170+
if ((modes & AuthenticationModes.OAuth) != 0) oauthItem = menu.Add("Web browser");
171+
if ((modes & AuthenticationModes.Pat) != 0) patItem = menu.Add("Personal access token");
172+
if ((modes & AuthenticationModes.Basic) != 0) basicItem = menu.Add("Username/password");
173+
174+
// Default to the 'first' choice in the menu
175+
TerminalMenuItem choice = menu.Show(0);
176+
177+
if (choice == oauthItem) goto case AuthenticationModes.OAuth;
178+
if (choice == basicItem) goto case AuthenticationModes.Basic;
179+
if (choice == patItem) goto case AuthenticationModes.Pat;
180+
181+
throw new Exception();
148182
}
149183
}
150184
}

src/shared/GitHub/GitHubConstants.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ public static class GitHubConstants
3535
/// <summary>
3636
/// Supported authentication modes for github.com.
3737
/// </summary>
38-
public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.OAuth;
38+
/// <remarks>
39+
/// As of 13th November 2020, github.com does not support username/password (basic) authentication to the APIs.
40+
/// See https://developer.github.com/changes/2020-02-14-deprecating-oauth-auth-endpoint for more information.
41+
/// </remarks>
42+
public const AuthenticationModes DotComAuthenticationModes = AuthenticationModes.OAuth | AuthenticationModes.Pat;
3943

4044
public static class TokenScopes
4145
{

src/shared/GitHub/GitHubHostProvider.cs

+23-4
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
9696
switch (promptResult.AuthenticationMode)
9797
{
9898
case AuthenticationModes.Basic:
99-
GitCredential patCredential = await GeneratePersonalAccessTokenAsync(remoteUri, promptResult.BasicCredential);
99+
GitCredential patCredential = await GeneratePersonalAccessTokenAsync(remoteUri, promptResult.Credential);
100100

101101
// HACK: Store the PAT immediately in case this PAT is not valid for SSO.
102102
// We don't know if this PAT is valid for SAML SSO and if it's not Git will fail
@@ -111,6 +111,22 @@ public override async Task<ICredential> GenerateCredentialAsync(InputArguments i
111111
case AuthenticationModes.OAuth:
112112
return await GenerateOAuthCredentialAsync(remoteUri);
113113

114+
case AuthenticationModes.Pat:
115+
// The token returned by the user should be good to use directly as the password for Git
116+
string token = promptResult.Credential.Password;
117+
118+
// Resolve the GitHub user handle if we don't have a specific username already from the
119+
// initial request. The reason for this is GitHub requires a (any?) value for the username
120+
// when Git makes calls to GitHub.
121+
string userName = promptResult.Credential.Account;
122+
if (userName is null)
123+
{
124+
GitHubUserInfo userInfo = await _gitHubApi.GetUserInfoAsync(remoteUri, token);
125+
userName = userInfo.Login;
126+
}
127+
128+
return new GitCredential(userName, token);
129+
114130
default:
115131
throw new ArgumentOutOfRangeException(nameof(promptResult));
116132
}
@@ -186,15 +202,16 @@ internal async Task<AuthenticationModes> GetSupportedAuthenticationModesAsync(Ur
186202
}
187203
}
188204

189-
// github.com should use OAuth authentication only
205+
// github.com should use OAuth or manual PAT based authentication only, never basic auth as of 13th November 2020
206+
// https://developer.github.com/changes/2020-02-14-deprecating-oauth-auth-endpoint
190207
if (IsGitHubDotCom(targetUri))
191208
{
192209
Context.Trace.WriteLine($"{targetUri} is github.com - authentication schemes: '{GitHubConstants.DotComAuthenticationModes}'");
193210
return GitHubConstants.DotComAuthenticationModes;
194211
}
195212

196213
// For GitHub Enterprise we must do some detection of supported modes
197-
Context.Trace.WriteLine($"{targetUri} is GitHub Enterprise - checking for supporting authentication schemes...");
214+
Context.Trace.WriteLine($"{targetUri} is GitHub Enterprise - checking for supported authentication schemes...");
198215

199216
try
200217
{
@@ -219,7 +236,9 @@ internal async Task<AuthenticationModes> GetSupportedAuthenticationModesAsync(Ur
219236
Context.Trace.WriteException(ex);
220237

221238
Context.Terminal.WriteLine($"warning: failed to query '{targetUri}' for supported authentication schemes.");
222-
return AuthenticationModes.Basic | AuthenticationModes.OAuth;
239+
240+
// Fall-back to offering all modes so the user is never blocked from authenticating by at least one mode
241+
return AuthenticationModes.All;
223242
}
224243
}
225244

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using Microsoft.Git.CredentialManager.Authentication;
4+
using Xunit;
5+
6+
namespace Microsoft.Git.CredentialManager.Tests.Authentication
7+
{
8+
public class AuthenticationBaseTests
9+
{
10+
[Theory]
11+
[InlineData("foo", "foo")]
12+
[InlineData("foo bar", "\"foo bar\"")]
13+
[InlineData("foo\nbar", "\"foo\nbar\"")]
14+
[InlineData("foo\rbar", "\"foo\rbar\"")]
15+
[InlineData("foo\tbar", "\"foo\tbar\"")]
16+
[InlineData("foo\" bar", "\"foo\\\" bar\"")]
17+
[InlineData("foo\"", "\"foo\\\"\"")]
18+
[InlineData("\"foo", "\"\\\"foo\"")]
19+
[InlineData("foo\\", "\"foo\\\\\"")]
20+
[InlineData("foo\\\"", "\"foo\\\\\\\"\"")]
21+
public void AuthenticationBase_QuoteCmdArg(string input, string expected)
22+
{
23+
string actual = AuthenticationBase.QuoteCmdArg(input);
24+
Assert.Equal(expected, actual);
25+
}
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using System;
2+
using Xunit;
3+
4+
namespace Microsoft.Git.CredentialManager.Tests
5+
{
6+
public class EnsureArgumentTests
7+
{
8+
[Theory]
9+
[InlineData(5, 0, 10, true, true)]
10+
[InlineData(0, 0, 10, true, true)]
11+
[InlineData(10, 0, 10, true, true)]
12+
[InlineData(0, -10, 0, true, true)]
13+
[InlineData(-10, -10, 0, true, true)]
14+
public void EnsureArgument_InRange_DoesNotThrow(int x, int lower, int upper, bool lowerInc, bool upperInc)
15+
{
16+
EnsureArgument.InRange(x, nameof(x), lower, upper, lowerInc, upperInc);
17+
}
18+
19+
[Theory]
20+
[InlineData(-3, 0, 10, true, true)]
21+
[InlineData(13, 0, 10, true, true)]
22+
[InlineData(10, 0, 10, true, false)]
23+
[InlineData(0, 0, 10, false, true)]
24+
[InlineData(-10, -10, 0, false, true)]
25+
[InlineData(0, -10, 0, true, false)]
26+
public void EnsureArgument_InRange_ThrowsException(int x, int lower, int upper, bool lowerInc, bool upperInc)
27+
{
28+
Assert.Throws<ArgumentOutOfRangeException>(
29+
() => EnsureArgument.InRange(x, nameof(x), lower, upper, lowerInc, upperInc)
30+
);
31+
}
32+
}
33+
}

src/shared/Microsoft.Git.CredentialManager/Authentication/AuthenticationBase.cs

+19
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
using System.Collections.Generic;
55
using System.Diagnostics;
66
using System.IO;
7+
using System.Linq;
78
using System.Reflection;
9+
using System.Text;
810
using System.Threading.Tasks;
911

1012
namespace Microsoft.Git.CredentialManager.Authentication
@@ -137,5 +139,22 @@ protected bool TryFindHelperExecutablePath(string envar, string configName, stri
137139

138140
return true;
139141
}
142+
143+
public static string QuoteCmdArg(string str)
144+
{
145+
char[] needsQuoteChars = {'"', ' ', '\\', '\n', '\r', '\t'};
146+
bool needsQuotes = str.Any(x => needsQuoteChars.Contains(x));
147+
148+
if (!needsQuotes)
149+
{
150+
return str;
151+
}
152+
153+
// Replace all '\' characters with an escaped '\\', and all '"' with '\"'
154+
string escapedStr = str.Replace("\\", "\\\\").Replace("\"", "\\\"");
155+
156+
// Bookend the escaped string with double-quotes '"'
157+
return $"\"{escapedStr}\"";
158+
}
140159
}
141160
}

src/shared/Microsoft.Git.CredentialManager/EnsureArgument.cs

+23
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,28 @@ public static void Negative(int arg, string name)
7575
throw new ArgumentOutOfRangeException(name, "Argument must be negative.");
7676
}
7777
}
78+
79+
public static void InRange(int arg, string name, int lower, int upper, bool lowerInclusive = true, bool upperInclusive = true)
80+
{
81+
if (lowerInclusive && arg < lower)
82+
{
83+
throw new ArgumentOutOfRangeException(name, $"Argument must be greater than or equal to {lower}.");
84+
}
85+
86+
if (!lowerInclusive && arg <= lower)
87+
{
88+
throw new ArgumentOutOfRangeException(name, $"Argument must be strictly greater than {lower}.");
89+
}
90+
91+
if (upperInclusive && arg > upper)
92+
{
93+
throw new ArgumentOutOfRangeException(name, $"Argument must be less than or equal to {upper}.");
94+
}
95+
96+
if (!upperInclusive && arg >= upper)
97+
{
98+
throw new ArgumentOutOfRangeException(name, $"Argument must be strictly less than {upper}.");
99+
}
100+
}
78101
}
79102
}

0 commit comments

Comments
 (0)