Skip to content

Commit a2425b9

Browse files
committed
introduce PasswordExpiryUTC and OAuthRefreshToken
Add properties ICredential.PasswordExpiryUTC and ICredential.OAuthRefreshToken. These correspond to Git credential attributes password_expiry_utc and oauth_refresh_token, see https://git-scm.com/docs/git-credential#IOFMT. Previously these attributes were silently disarded. Plumb these properties from input to host provider to credential store to output. Credential store support for these attributes is optional, marked by new properties ICredentialStore.CanStorePasswordExpiryUTC and ICredentialStore.CanStoreOAuthRefreshToken. Implement support in CredentialCacheStore, SecretServiceCollection and WindowsCredentialManager. Add method IHostProvider.ValidateCredentialAsync. The default implementation simply checks expiry. Improve implementations of GenericHostProvider and GitLabHostProvider. Previously, GetCredentialAsync saved credentials as a side effect. This is no longer necessary. The workaround to store OAuth refresh tokens under a separate service is no longer necessary assuming CredentialStore.CanStoreOAuthRefreshToken. Querying GitLab to check token expiration is no longer necessary assuming CredentialStore.CanStorePasswordExpiryUTC.
1 parent 2365cac commit a2425b9

24 files changed

+436
-156
lines changed

src/shared/Core.Tests/Commands/GetCommandTests.cs

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using System.Collections.Generic;
23
using System.IO;
34
using System.Text;
@@ -16,14 +17,21 @@ public async Task GetCommand_ExecuteAsync_CallsHostProviderAndWritesCredential()
1617
{
1718
const string testUserName = "john.doe";
1819
const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
19-
ICredential testCredential = new GitCredential(testUserName, testPassword);
20+
const string testRefreshToken = "xyzzy";
21+
const long testExpiry = 1919539847;
22+
ICredential testCredential = new GitCredential(testUserName, testPassword) {
23+
OAuthRefreshToken = testRefreshToken,
24+
PasswordExpiry = DateTimeOffset.FromUnixTimeSeconds(testExpiry),
25+
};
2026
var stdin = $"protocol=http\nhost=example.com\n\n";
2127
var expectedStdOutDict = new Dictionary<string, string>
2228
{
2329
["protocol"] = "http",
2430
["host"] = "example.com",
2531
["username"] = testUserName,
26-
["password"] = testPassword
32+
["password"] = testPassword,
33+
["password_expiry_utc"] = testExpiry.ToString(),
34+
["oauth_refresh_token"] = testRefreshToken,
2735
};
2836

2937
var providerMock = new Mock<IHostProvider>();

src/shared/Core.Tests/Commands/StoreCommandTests.cs

+9-3
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ public async Task StoreCommand_ExecuteAsync_CallsHostProvider()
1313
{
1414
const string testUserName = "john.doe";
1515
const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
16-
var stdin = $"protocol=http\nhost=example.com\nusername={testUserName}\npassword={testPassword}\n\n";
16+
const string testRefreshToken = "xyzzy";
17+
const long testExpiry = 1919539847;
18+
var stdin = $"protocol=http\nhost=example.com\nusername={testUserName}\npassword={testPassword}\noauth_refresh_token={testRefreshToken}\npassword_expiry_utc={testExpiry}\n\n";
1719
var expectedInput = new InputArguments(new Dictionary<string, string>
1820
{
1921
["protocol"] = "http",
2022
["host"] = "example.com",
2123
["username"] = testUserName,
22-
["password"] = testPassword
24+
["password"] = testPassword,
25+
["oauth_refresh_token"] = testRefreshToken,
26+
["password_expiry_utc"] = testExpiry.ToString(),
2327
});
2428

2529
var providerMock = new Mock<IHostProvider>();
@@ -46,7 +50,9 @@ bool AreInputArgumentsEquivalent(InputArguments a, InputArguments b)
4650
a.Host == b.Host &&
4751
a.Path == b.Path &&
4852
a.UserName == b.UserName &&
49-
a.Password == b.Password;
53+
a.Password == b.Password &&
54+
a.OAuthRefreshToken == b.OAuthRefreshToken &&
55+
a.PasswordExpiry == b.PasswordExpiry;
5056
}
5157
}
5258
}

src/shared/Core.Tests/GenericHostProviderTests.cs

+2-6
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,6 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut
201201
const string testAcessToken = "OAUTH_TOKEN";
202202
const string testRefreshToken = "OAUTH_REFRESH_TOKEN";
203203
const string testResource = "https://git.example.com/foo";
204-
const string expectedRefreshTokenService = "https://refresh_token.git.example.com/foo";
205204

206205
var authMode = OAuthAuthenticationModes.Browser;
207206
string[] scopes = { "code:write", "code:read" };
@@ -249,7 +248,7 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut
249248
.ReturnsAsync(new OAuth2TokenResult(testAcessToken, "access_token")
250249
{
251250
Scopes = scopes,
252-
RefreshToken = testRefreshToken
251+
RefreshToken = testRefreshToken,
253252
});
254253

255254
var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object);
@@ -259,10 +258,7 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut
259258
Assert.NotNull(credential);
260259
Assert.Equal(testUserName, credential.Account);
261260
Assert.Equal(testAcessToken, credential.Password);
262-
263-
Assert.True(context.CredentialStore.TryGet(expectedRefreshTokenService, null, out TestCredential refreshToken));
264-
Assert.Equal(testUserName, refreshToken.Account);
265-
Assert.Equal(testRefreshToken, refreshToken.Password);
261+
Assert.Equal(testRefreshToken, credential.OAuthRefreshToken);
266262

267263
oauthMock.Verify(x => x.GetAuthenticationModeAsync(testResource, OAuthAuthenticationModes.All), Times.Once);
268264
oauthMock.Verify(x => x.GetTokenByBrowserAsync(It.IsAny<OAuth2Client>(), scopes), Times.Once);

src/shared/Core.Tests/HostProviderTests.cs

+62-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System;
12
using System.Collections.Generic;
3+
using System.Runtime;
24
using System.Threading.Tasks;
35
using GitCredentialManager.Tests.Objects;
46
using Xunit;
@@ -15,16 +17,16 @@ public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExisti
1517
const string userName = "john.doe";
1618
const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
1719
const string service = "https://example.com";
20+
const string refreshToken = "xyzzy";
21+
DateTimeOffset expiry = DateTimeOffset.FromUnixTimeSeconds(1919539847);
1822
var input = new InputArguments(new Dictionary<string, string>
1923
{
2024
["protocol"] = "https",
2125
["host"] = "example.com",
22-
["username"] = userName,
23-
["password"] = password, // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
2426
});
2527

2628
var context = new TestCommandContext();
27-
context.CredentialStore.Add(service, userName, password);
29+
context.CredentialStore.Add(service, new TestCredential(service, userName, password) { OAuthRefreshToken = refreshToken, PasswordExpiry = expiry});
2830
var provider = new TestHostProvider(context)
2931
{
3032
IsSupportedFunc = _ => true,
@@ -39,6 +41,8 @@ public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExisti
3941

4042
Assert.Equal(userName, actualCredential.Account);
4143
Assert.Equal(password, actualCredential.Password);
44+
Assert.Equal(refreshToken, actualCredential.OAuthRefreshToken);
45+
Assert.Equal(expiry, actualCredential.PasswordExpiry);
4246
}
4347

4448
[Fact]
@@ -50,8 +54,6 @@ public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_Returns
5054
{
5155
["protocol"] = "https",
5256
["host"] = "example.com",
53-
["username"] = userName,
54-
["password"] = password, // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
5557
});
5658

5759
bool generateWasCalled = false;
@@ -73,6 +75,49 @@ public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_Returns
7375
Assert.Equal(password, actualCredential.Password);
7476
}
7577

78+
[Fact]
79+
public async Task HostProvider_GetCredentialAsync_InvalidCredentialStored_ReturnsNewGeneratedCredential()
80+
{
81+
const string userName = "john.doe";
82+
const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
83+
const string service = "https://example.com";
84+
const string storedRefreshToken = "first";
85+
const string refreshToken = "second";
86+
var input = new InputArguments(new Dictionary<string, string>
87+
{
88+
["protocol"] = "https",
89+
["host"] = "example.com",
90+
});
91+
92+
bool generateWasCalled = false;
93+
string refreshTokenSeenByGenerate = null;
94+
var context = new TestCommandContext();
95+
context.CredentialStore.Add(service, new TestCredential(service, "stored-user", "stored-password") { OAuthRefreshToken = storedRefreshToken});
96+
var provider = new TestHostProvider(context)
97+
{
98+
ValidateCredentialFunc = (_, _) => false,
99+
IsSupportedFunc = _ => true,
100+
GenerateCredentialFunc = input =>
101+
{
102+
generateWasCalled = true;
103+
refreshTokenSeenByGenerate = input.OAuthRefreshToken;
104+
return new GitCredential(userName, password) {
105+
OAuthRefreshToken = refreshToken,
106+
};
107+
},
108+
};
109+
110+
ICredential actualCredential = await ((IHostProvider) provider).GetCredentialAsync(input);
111+
112+
Assert.True(generateWasCalled);
113+
Assert.Equal(storedRefreshToken, refreshTokenSeenByGenerate);
114+
Assert.Equal(userName, actualCredential.Account);
115+
Assert.Equal(password, actualCredential.Password);
116+
Assert.Equal(refreshToken, actualCredential.OAuthRefreshToken);
117+
// Invalid credential should be erased
118+
Assert.Equal(0, context.CredentialStore.Count);
119+
}
120+
76121

77122
#endregion
78123

@@ -252,6 +297,18 @@ public async Task HostProvider_EraseCredentialAsync_DifferentHost_DoesNothing()
252297
Assert.True(context.CredentialStore.Contains(service3, userName));
253298
}
254299

300+
[Fact]
301+
public void HostProvider_ValidateCredentialAsync()
302+
{
303+
var context = new TestCommandContext();
304+
var provider = new TestHostProvider(context);
305+
Assert.True(provider.ValidateCredentialAsync(null, new GitCredential("username", "pass")).Result);
306+
Assert.True(provider.ValidateCredentialAsync(null, new GitCredential("username", "pass") {PasswordExpiry
307+
= DateTimeOffset.UtcNow + TimeSpan.FromHours(1)}).Result);
308+
Assert.False(provider.ValidateCredentialAsync(null, new GitCredential("username", "pass") {PasswordExpiry
309+
= DateTimeOffset.UtcNow - TimeSpan.FromMinutes(1)}).Result);
310+
}
311+
255312
#endregion
256313
}
257314
}

src/shared/Core.Tests/Interop/Linux/SecretServiceCollectionTests.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,22 @@ public void SecretServiceCollection_ReadWriteDelete()
1717
string service = $"https://example.com/{Guid.NewGuid():N}";
1818
const string userName = "john.doe";
1919
const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
20+
const string testRefreshToken = "xyzzy";
21+
DateTimeOffset testExpiry = DateTimeOffset.FromUnixTimeSeconds(1919539847);
2022

2123
try
2224
{
2325
// Write
24-
collection.AddOrUpdate(service, userName, password);
26+
collection.AddOrUpdate(service, new GitCredential(userName, password) { PasswordExpiry = testExpiry, OAuthRefreshToken = testRefreshToken});
2527

2628
// Read
2729
ICredential outCredential = collection.Get(service, userName);
2830

2931
Assert.NotNull(outCredential);
3032
Assert.Equal(userName, userName);
3133
Assert.Equal(password, outCredential.Password);
34+
Assert.Equal(testRefreshToken, outCredential.OAuthRefreshToken);
35+
Assert.Equal(testExpiry, outCredential.PasswordExpiry);
3236
}
3337
finally
3438
{

src/shared/Core.Tests/Interop/Windows/WindowsCredentialManagerTests.cs

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ public void WindowsCredentialManager_ReadWriteDelete()
1919
string service = $"https://example.com/{uniqueGuid}";
2020
const string userName = "john.doe";
2121
const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")]
22+
const string testRefreshToken = "xyzzy";
2223

2324
string expectedTargetName = $"{TestNamespace}:https://example.com/{uniqueGuid}";
2425

2526
try
2627
{
2728
// Write
28-
credManager.AddOrUpdate(service, userName, password);
29+
credManager.AddOrUpdate(service, new GitCredential(userName, password) { OAuthRefreshToken = testRefreshToken});
2930

3031
// Read
3132
ICredential cred = credManager.Get(service, userName);
@@ -37,6 +38,7 @@ public void WindowsCredentialManager_ReadWriteDelete()
3738
Assert.Equal(password, winCred.Password);
3839
Assert.Equal(service, winCred.Service);
3940
Assert.Equal(expectedTargetName, winCred.TargetName);
41+
Assert.Equal(testRefreshToken, winCred.OAuthRefreshToken);
4042
}
4143
finally
4244
{

src/shared/Core/Commands/GetCommand.cs

+5-1
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,13 @@ protected override async Task ExecuteInternalAsync(InputArguments input, IHostPr
3535
// Return the credential to Git
3636
output["username"] = credential.Account;
3737
output["password"] = credential.Password;
38+
if (credential.PasswordExpiry.HasValue)
39+
output["password_expiry_utc"] = credential.PasswordExpiry.Value.ToUnixTimeSeconds().ToString();
40+
if (!string.IsNullOrEmpty(credential.OAuthRefreshToken))
41+
output["oauth_refresh_token"] = credential.OAuthRefreshToken;
3842

3943
Context.Trace.WriteLine("Writing credentials to output:");
40-
Context.Trace.WriteDictionarySecrets(output, new []{ "password" }, StringComparer.OrdinalIgnoreCase);
44+
Context.Trace.WriteDictionarySecrets(output, new []{ "password", "oauth_refresh_token" }, StringComparer.OrdinalIgnoreCase);
4145

4246
// Write the values to standard out
4347
Context.Streams.Out.WriteDictionary(output);

src/shared/Core/Commands/GitCommandBase.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ internal async Task ExecuteAsync()
4444

4545
// Determine the host provider
4646
Context.Trace.WriteLine("Detecting host provider for input:");
47-
Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password" }, StringComparer.OrdinalIgnoreCase);
47+
Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password", "oauth_refresh_token" }, StringComparer.OrdinalIgnoreCase);
4848
IHostProvider provider = await _hostProviderRegistry.GetProviderAsync(input);
4949
Context.Trace.WriteLine($"Host provider '{provider.Name}' was selected.");
5050

src/shared/Core/Credential.cs

+38-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11

2+
using System;
3+
using GitCredentialManager.Authentication.OAuth;
4+
25
namespace GitCredentialManager
36
{
47
/// <summary>
@@ -15,21 +18,53 @@ public interface ICredential
1518
/// Password.
1619
/// </summary>
1720
string Password { get; }
21+
22+
/// <summary>
23+
/// The expiry date of the password. This is Git's password_expiry_utc
24+
/// attribute. https://git-scm.com/docs/git-credential#Documentation/git-credential.txt-codepasswordexpiryutccode
25+
/// </summary>
26+
DateTimeOffset? PasswordExpiry { get => null; }
27+
28+
string OAuthRefreshToken { get => null; }
1829
}
1930

2031
/// <summary>
2132
/// Represents a credential (username/password pair) that Git can use to authenticate to a remote repository.
2233
/// </summary>
23-
public class GitCredential : ICredential
34+
public record GitCredential : ICredential
2435
{
2536
public GitCredential(string userName, string password)
2637
{
2738
Account = userName;
2839
Password = password;
2940
}
3041

31-
public string Account { get; }
42+
public GitCredential(InputArguments input)
43+
{
44+
Account = input.UserName;
45+
Password = input.Password;
46+
OAuthRefreshToken = input.OAuthRefreshToken;
47+
if (long.TryParse(input.PasswordExpiry, out long x)) {
48+
PasswordExpiry = DateTimeOffset.FromUnixTimeSeconds(x);
49+
}
50+
}
51+
52+
public GitCredential(OAuth2TokenResult tokenResult, string userName)
53+
{
54+
Account = userName;
55+
Password = tokenResult.AccessToken;
56+
OAuthRefreshToken = tokenResult.RefreshToken;
57+
if (tokenResult.ExpiresIn != null) {
58+
PasswordExpiry = DateTimeOffset.UtcNow + tokenResult.ExpiresIn.Value;
59+
}
60+
}
61+
62+
public string Account { get; init; }
63+
64+
public string Password { get; init; }
3265

33-
public string Password { get; }
66+
public DateTimeOffset? PasswordExpiry { get; init; }
67+
68+
public string OAuthRefreshToken { get; init; }
3469
}
3570
}

0 commit comments

Comments
 (0)