diff --git a/src/shared/Core.Tests/Commands/GetCommandTests.cs b/src/shared/Core.Tests/Commands/GetCommandTests.cs index f808247941..ca4e31b223 100644 --- a/src/shared/Core.Tests/Commands/GetCommandTests.cs +++ b/src/shared/Core.Tests/Commands/GetCommandTests.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Text; @@ -16,14 +17,21 @@ public async Task GetCommand_ExecuteAsync_CallsHostProviderAndWritesCredential() { const string testUserName = "john.doe"; const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] - ICredential testCredential = new GitCredential(testUserName, testPassword); + const string testRefreshToken = "xyzzy"; + const long testExpiry = 1919539847; + ICredential testCredential = new GitCredential(testUserName, testPassword) { + OAuthRefreshToken = testRefreshToken, + PasswordExpiry = DateTimeOffset.FromUnixTimeSeconds(testExpiry), + }; var stdin = $"protocol=http\nhost=example.com\n\n"; var expectedStdOutDict = new Dictionary { ["protocol"] = "http", ["host"] = "example.com", ["username"] = testUserName, - ["password"] = testPassword + ["password"] = testPassword, + ["password_expiry_utc"] = testExpiry.ToString(), + ["oauth_refresh_token"] = testRefreshToken, }; var providerMock = new Mock(); diff --git a/src/shared/Core.Tests/Commands/StoreCommandTests.cs b/src/shared/Core.Tests/Commands/StoreCommandTests.cs index e7cd4acadd..a770f7099f 100644 --- a/src/shared/Core.Tests/Commands/StoreCommandTests.cs +++ b/src/shared/Core.Tests/Commands/StoreCommandTests.cs @@ -13,13 +13,17 @@ public async Task StoreCommand_ExecuteAsync_CallsHostProvider() { const string testUserName = "john.doe"; const string testPassword = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] - var stdin = $"protocol=http\nhost=example.com\nusername={testUserName}\npassword={testPassword}\n\n"; + const string testRefreshToken = "xyzzy"; + const long testExpiry = 1919539847; + var stdin = $"protocol=http\nhost=example.com\nusername={testUserName}\npassword={testPassword}\noauth_refresh_token={testRefreshToken}\npassword_expiry_utc={testExpiry}\n\n"; var expectedInput = new InputArguments(new Dictionary { ["protocol"] = "http", ["host"] = "example.com", ["username"] = testUserName, - ["password"] = testPassword + ["password"] = testPassword, + ["oauth_refresh_token"] = testRefreshToken, + ["password_expiry_utc"] = testExpiry.ToString(), }); var providerMock = new Mock(); @@ -46,7 +50,9 @@ bool AreInputArgumentsEquivalent(InputArguments a, InputArguments b) a.Host == b.Host && a.Path == b.Path && a.UserName == b.UserName && - a.Password == b.Password; + a.Password == b.Password && + a.OAuthRefreshToken == b.OAuthRefreshToken && + a.PasswordExpiry == b.PasswordExpiry; } } } diff --git a/src/shared/Core.Tests/GenericHostProviderTests.cs b/src/shared/Core.Tests/GenericHostProviderTests.cs index 39ed85cfe3..031ef4ab42 100644 --- a/src/shared/Core.Tests/GenericHostProviderTests.cs +++ b/src/shared/Core.Tests/GenericHostProviderTests.cs @@ -201,7 +201,6 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut const string testAcessToken = "OAUTH_TOKEN"; const string testRefreshToken = "OAUTH_REFRESH_TOKEN"; const string testResource = "https://git.example.com/foo"; - const string expectedRefreshTokenService = "https://refresh_token.git.example.com/foo"; var authMode = OAuthAuthenticationModes.Browser; string[] scopes = { "code:write", "code:read" }; @@ -249,7 +248,7 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut .ReturnsAsync(new OAuth2TokenResult(testAcessToken, "access_token") { Scopes = scopes, - RefreshToken = testRefreshToken + RefreshToken = testRefreshToken, }); var provider = new GenericHostProvider(context, basicAuthMock.Object, wiaAuthMock.Object, oauthMock.Object); @@ -259,10 +258,7 @@ public async Task GenericHostProvider_GenerateCredentialAsync_OAuth_CompleteOAut Assert.NotNull(credential); Assert.Equal(testUserName, credential.Account); Assert.Equal(testAcessToken, credential.Password); - - Assert.True(context.CredentialStore.TryGet(expectedRefreshTokenService, null, out TestCredential refreshToken)); - Assert.Equal(testUserName, refreshToken.Account); - Assert.Equal(testRefreshToken, refreshToken.Password); + Assert.Equal(testRefreshToken, credential.OAuthRefreshToken); oauthMock.Verify(x => x.GetAuthenticationModeAsync(testResource, OAuthAuthenticationModes.All), Times.Once); oauthMock.Verify(x => x.GetTokenByBrowserAsync(It.IsAny(), scopes), Times.Once); diff --git a/src/shared/Core.Tests/HostProviderTests.cs b/src/shared/Core.Tests/HostProviderTests.cs index 64dd444d9a..d4b93c6dfb 100644 --- a/src/shared/Core.Tests/HostProviderTests.cs +++ b/src/shared/Core.Tests/HostProviderTests.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Runtime; using System.Threading.Tasks; using GitCredentialManager.Tests.Objects; using Xunit; @@ -15,16 +17,16 @@ public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExisti const string userName = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] const string service = "https://example.com"; + const string refreshToken = "xyzzy"; + DateTimeOffset expiry = DateTimeOffset.FromUnixTimeSeconds(1919539847); var input = new InputArguments(new Dictionary { ["protocol"] = "https", ["host"] = "example.com", - ["username"] = userName, - ["password"] = password, // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] }); var context = new TestCommandContext(); - context.CredentialStore.Add(service, userName, password); + context.CredentialStore.Add(service, new TestCredential(service, userName, password) { OAuthRefreshToken = refreshToken, PasswordExpiry = expiry}); var provider = new TestHostProvider(context) { IsSupportedFunc = _ => true, @@ -39,6 +41,8 @@ public async Task HostProvider_GetCredentialAsync_CredentialExists_ReturnsExisti Assert.Equal(userName, actualCredential.Account); Assert.Equal(password, actualCredential.Password); + Assert.Equal(refreshToken, actualCredential.OAuthRefreshToken); + Assert.Equal(expiry, actualCredential.PasswordExpiry); } [Fact] @@ -50,8 +54,6 @@ public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_Returns { ["protocol"] = "https", ["host"] = "example.com", - ["username"] = userName, - ["password"] = password, // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] }); bool generateWasCalled = false; @@ -73,6 +75,49 @@ public async Task HostProvider_GetCredentialAsync_CredentialDoesNotExist_Returns Assert.Equal(password, actualCredential.Password); } + [Fact] + public async Task HostProvider_GetCredentialAsync_InvalidCredentialStored_ReturnsNewGeneratedCredential() + { + const string userName = "john.doe"; + const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + const string service = "https://example.com"; + const string storedRefreshToken = "first"; + const string refreshToken = "second"; + var input = new InputArguments(new Dictionary + { + ["protocol"] = "https", + ["host"] = "example.com", + }); + + bool generateWasCalled = false; + string refreshTokenSeenByGenerate = null; + var context = new TestCommandContext(); + context.CredentialStore.Add(service, new TestCredential(service, "stored-user", "stored-password") { OAuthRefreshToken = storedRefreshToken}); + var provider = new TestHostProvider(context) + { + ValidateCredentialFunc = (_, _) => false, + IsSupportedFunc = _ => true, + GenerateCredentialFunc = input => + { + generateWasCalled = true; + refreshTokenSeenByGenerate = input.OAuthRefreshToken; + return new GitCredential(userName, password) { + OAuthRefreshToken = refreshToken, + }; + }, + }; + + ICredential actualCredential = await ((IHostProvider) provider).GetCredentialAsync(input); + + Assert.True(generateWasCalled); + Assert.Equal(storedRefreshToken, refreshTokenSeenByGenerate); + Assert.Equal(userName, actualCredential.Account); + Assert.Equal(password, actualCredential.Password); + Assert.Equal(refreshToken, actualCredential.OAuthRefreshToken); + // Invalid credential should be erased + Assert.Equal(0, context.CredentialStore.Count); + } + #endregion @@ -252,6 +297,18 @@ public async Task HostProvider_EraseCredentialAsync_DifferentHost_DoesNothing() Assert.True(context.CredentialStore.Contains(service3, userName)); } + [Fact] + public void HostProvider_ValidateCredentialAsync() + { + var context = new TestCommandContext(); + var provider = new TestHostProvider(context); + Assert.True(provider.ValidateCredentialAsync(null, new GitCredential("username", "pass")).Result); + Assert.True(provider.ValidateCredentialAsync(null, new GitCredential("username", "pass") {PasswordExpiry + = DateTimeOffset.UtcNow + TimeSpan.FromHours(1)}).Result); + Assert.False(provider.ValidateCredentialAsync(null, new GitCredential("username", "pass") {PasswordExpiry + = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(1)}).Result); + } + #endregion } } diff --git a/src/shared/Core.Tests/Interop/Linux/SecretServiceCollectionTests.cs b/src/shared/Core.Tests/Interop/Linux/SecretServiceCollectionTests.cs index 1237e2907c..3e540eae09 100644 --- a/src/shared/Core.Tests/Interop/Linux/SecretServiceCollectionTests.cs +++ b/src/shared/Core.Tests/Interop/Linux/SecretServiceCollectionTests.cs @@ -17,11 +17,13 @@ public void SecretServiceCollection_ReadWriteDelete() string service = $"https://example.com/{Guid.NewGuid():N}"; const string userName = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + const string testRefreshToken = "xyzzy"; + DateTimeOffset testExpiry = DateTimeOffset.FromUnixTimeSeconds(1919539847); try { // Write - collection.AddOrUpdate(service, userName, password); + collection.AddOrUpdate(service, new GitCredential(userName, password) { PasswordExpiry = testExpiry, OAuthRefreshToken = testRefreshToken}); // Read ICredential outCredential = collection.Get(service, userName); @@ -29,6 +31,8 @@ public void SecretServiceCollection_ReadWriteDelete() Assert.NotNull(outCredential); Assert.Equal(userName, userName); Assert.Equal(password, outCredential.Password); + Assert.Equal(testRefreshToken, outCredential.OAuthRefreshToken); + Assert.Equal(testExpiry, outCredential.PasswordExpiry); } finally { diff --git a/src/shared/Core.Tests/Interop/Windows/WindowsCredentialManagerTests.cs b/src/shared/Core.Tests/Interop/Windows/WindowsCredentialManagerTests.cs index b7b5cef627..4d66d0fd28 100644 --- a/src/shared/Core.Tests/Interop/Windows/WindowsCredentialManagerTests.cs +++ b/src/shared/Core.Tests/Interop/Windows/WindowsCredentialManagerTests.cs @@ -19,13 +19,14 @@ public void WindowsCredentialManager_ReadWriteDelete() string service = $"https://example.com/{uniqueGuid}"; const string userName = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + const string testRefreshToken = "xyzzy"; string expectedTargetName = $"{TestNamespace}:https://example.com/{uniqueGuid}"; try { // Write - credManager.AddOrUpdate(service, userName, password); + credManager.AddOrUpdate(service, new GitCredential(userName, password) { OAuthRefreshToken = testRefreshToken}); // Read ICredential cred = credManager.Get(service, userName); @@ -37,6 +38,7 @@ public void WindowsCredentialManager_ReadWriteDelete() Assert.Equal(password, winCred.Password); Assert.Equal(service, winCred.Service); Assert.Equal(expectedTargetName, winCred.TargetName); + Assert.Equal(testRefreshToken, winCred.OAuthRefreshToken); } finally { diff --git a/src/shared/Core/Commands/GetCommand.cs b/src/shared/Core/Commands/GetCommand.cs index 8cc1bff7d2..dbd9057932 100644 --- a/src/shared/Core/Commands/GetCommand.cs +++ b/src/shared/Core/Commands/GetCommand.cs @@ -35,9 +35,13 @@ protected override async Task ExecuteInternalAsync(InputArguments input, IHostPr // Return the credential to Git output["username"] = credential.Account; output["password"] = credential.Password; + if (credential.PasswordExpiry.HasValue) + output["password_expiry_utc"] = credential.PasswordExpiry.Value.ToUnixTimeSeconds().ToString(); + if (!string.IsNullOrEmpty(credential.OAuthRefreshToken)) + output["oauth_refresh_token"] = credential.OAuthRefreshToken; Context.Trace.WriteLine("Writing credentials to output:"); - Context.Trace.WriteDictionarySecrets(output, new []{ "password" }, StringComparer.OrdinalIgnoreCase); + Context.Trace.WriteDictionarySecrets(output, new []{ "password", "oauth_refresh_token" }, StringComparer.OrdinalIgnoreCase); // Write the values to standard out Context.Streams.Out.WriteDictionary(output); diff --git a/src/shared/Core/Commands/GitCommandBase.cs b/src/shared/Core/Commands/GitCommandBase.cs index b277d1a757..4d4ed40879 100644 --- a/src/shared/Core/Commands/GitCommandBase.cs +++ b/src/shared/Core/Commands/GitCommandBase.cs @@ -44,7 +44,7 @@ internal async Task ExecuteAsync() // Determine the host provider Context.Trace.WriteLine("Detecting host provider for input:"); - Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password" }, StringComparer.OrdinalIgnoreCase); + Context.Trace.WriteDictionarySecrets(inputDict, new []{ "password", "oauth_refresh_token" }, StringComparer.OrdinalIgnoreCase); IHostProvider provider = await _hostProviderRegistry.GetProviderAsync(input); Context.Trace.WriteLine($"Host provider '{provider.Name}' was selected."); diff --git a/src/shared/Core/Credential.cs b/src/shared/Core/Credential.cs index 0a6130eae3..4d05efd8d7 100644 --- a/src/shared/Core/Credential.cs +++ b/src/shared/Core/Credential.cs @@ -1,4 +1,7 @@ +using System; +using GitCredentialManager.Authentication.OAuth; + namespace GitCredentialManager { /// @@ -15,12 +18,24 @@ public interface ICredential /// Password. /// string Password { get; } + + /// + /// The expiry date of the password. This is Git's password_expiry_utc + /// attribute. https://git-scm.com/docs/git-credential#Documentation/git-credential.txt-codepasswordexpiryutccode + /// + DateTimeOffset? PasswordExpiry { get => null; } + + /// + /// An OAuth refresh token. This is Git's oauth_refresh_token + /// attribute. https://git-scm.com/docs/git-credential#Documentation/git-credential.txt-codeoauthrefreshtokencode + /// + string OAuthRefreshToken { get => null; } } /// /// Represents a credential (username/password pair) that Git can use to authenticate to a remote repository. /// - public class GitCredential : ICredential + public record GitCredential : ICredential { public GitCredential(string userName, string password) { @@ -28,8 +43,32 @@ public GitCredential(string userName, string password) Password = password; } - public string Account { get; } + public GitCredential(InputArguments input) + { + Account = input.UserName; + Password = input.Password; + OAuthRefreshToken = input.OAuthRefreshToken; + if (long.TryParse(input.PasswordExpiry, out long x)) { + PasswordExpiry = DateTimeOffset.FromUnixTimeSeconds(x); + } + } + + public GitCredential(OAuth2TokenResult tokenResult, string userName) + { + Account = userName; + Password = tokenResult.AccessToken; + OAuthRefreshToken = tokenResult.RefreshToken; + if (tokenResult.ExpiresIn.HasValue) { + PasswordExpiry = DateTimeOffset.UtcNow + tokenResult.ExpiresIn.Value; + } + } + + public string Account { get; init; } + + public string Password { get; init; } - public string Password { get; } + public DateTimeOffset? PasswordExpiry { get; init; } + + public string OAuthRefreshToken { get; init; } } } diff --git a/src/shared/Core/CredentialCacheStore.cs b/src/shared/Core/CredentialCacheStore.cs index 41d3ffd3c0..1c4d03d40c 100644 --- a/src/shared/Core/CredentialCacheStore.cs +++ b/src/shared/Core/CredentialCacheStore.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using GitCredentialManager.Authentication.OAuth; namespace GitCredentialManager { @@ -42,9 +45,10 @@ public IList GetAccounts(string service) return Array.Empty(); } + public ICredential Get(string service, string account) { - var input = MakeGitCredentialsEntry(service, account); + var input = MakeGitCredentialsEntry(service, new GitCredential(account, null)); var result = _git.InvokeHelperAsync( $"credential-cache get {_options}", @@ -53,16 +57,23 @@ public ICredential Get(string service, string account) if (result.ContainsKey("username") && result.ContainsKey("password")) { - return new GitCredential(result["username"], result["password"]); + DateTimeOffset? PasswordExpiry = null; + if (result.ContainsKey("password_expiry_utc") && long.TryParse(result["password_expiry_utc"], out long x)) { + PasswordExpiry = DateTimeOffset.FromUnixTimeSeconds(x); + } + return new GitCredential(result["username"], result["password"]) { + + PasswordExpiry = PasswordExpiry, + OAuthRefreshToken = result.ContainsKey("oauth_refresh_token") ? result["oauth_refresh_token"] : null, + }; } return null; } - public void AddOrUpdate(string service, string account, string secret) + public void AddOrUpdate(string service, ICredential credential) { - var input = MakeGitCredentialsEntry(service, account); - input["password"] = secret; + var input = MakeGitCredentialsEntry(service, credential); // per https://git-scm.com/docs/gitcredentials : // For a store or erase operation, the helper’s output is ignored. @@ -72,9 +83,9 @@ public void AddOrUpdate(string service, string account, string secret) ).GetAwaiter().GetResult(); } - public bool Remove(string service, string account) + public bool Remove(string service, ICredential credential) { - var input = MakeGitCredentialsEntry(service, account); + var input = MakeGitCredentialsEntry(service, credential); // per https://git-scm.com/docs/gitcredentials : // For a store or erase operation, the helper’s output is ignored. @@ -90,17 +101,38 @@ public bool Remove(string service, string account) #endregion - private Dictionary MakeGitCredentialsEntry(string service, string account) + private Dictionary MakeGitCredentialsEntry(string service, ICredential credential) { var result = new Dictionary(); result["url"] = service; - if (!string.IsNullOrEmpty(account)) + if (!string.IsNullOrEmpty(credential?.Account)) + { + result["username"] = credential.Account; + } + if (!string.IsNullOrEmpty(credential?.Password)) + { + result["password"] = credential.Password; + } + if (credential.PasswordExpiry.HasValue) { - result["username"] = account; + result["password_expiry_utc"] = credential.PasswordExpiry.Value.ToUnixTimeSeconds().ToString(); + } + if (!string.IsNullOrEmpty(credential?.OAuthRefreshToken)) + { + result["oauth_refresh_token"] = credential.OAuthRefreshToken; } return result; } + + public void AddOrUpdate(string service, string account, string secret) + => AddOrUpdate(service, new GitCredential(account, secret)); + + public bool Remove(string service, string account) + => Remove(service, new GitCredential(account, null)); + + public bool CanStorePasswordExpiry => true; + public bool CanStoreOAuthRefreshToken => true; } } diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs index a0c1ed861d..de486c1ee4 100644 --- a/src/shared/Core/CredentialStore.cs +++ b/src/shared/Core/CredentialStore.cs @@ -37,6 +37,7 @@ public ICredential Get(string service, string account) return _backingStore.Get(service, account); } + public void AddOrUpdate(string service, string account, string secret) { EnsureBackingStore(); @@ -49,6 +50,18 @@ public bool Remove(string service, string account) return _backingStore.Remove(service, account); } + public void AddOrUpdate(string service, ICredential credential) + { + EnsureBackingStore(); + _backingStore.AddOrUpdate(service, credential); + } + + public bool Remove(string service, ICredential credential) + { + EnsureBackingStore(); + return _backingStore.Remove(service, credential); + } + #endregion private void EnsureBackingStore() @@ -66,7 +79,7 @@ private void EnsureBackingStore() { case StoreNames.WindowsCredentialManager: ValidateWindowsCredentialManager(); - _backingStore = new WindowsCredentialManager(ns); + _backingStore = new WindowsCredentialManager(ns); break; case StoreNames.Dpapi: @@ -364,5 +377,28 @@ private string GetGpgPath() _context.Trace.WriteLine($"Using PATH-located GPG (gpg) executable: {gpgPath}"); return gpgPath; } + + public bool CanStoreOAuthRefreshToken + { + get + { + EnsureBackingStore(); + return _backingStore.CanStoreOAuthRefreshToken; + } + } + public bool CanStorePasswordExpiry + { + get + { + EnsureBackingStore(); + return _backingStore.CanStorePasswordExpiry; + } + } + + public override string ToString() + { + EnsureBackingStore(); + return $"{nameof(CredentialStore)} backed by {_backingStore}"; + } } } diff --git a/src/shared/Core/Diagnostics/CredentialStoreDiagnostic.cs b/src/shared/Core/Diagnostics/CredentialStoreDiagnostic.cs index 74f9ca2edc..acd3e04cba 100644 --- a/src/shared/Core/Diagnostics/CredentialStoreDiagnostic.cs +++ b/src/shared/Core/Diagnostics/CredentialStoreDiagnostic.cs @@ -13,17 +13,23 @@ public CredentialStoreDiagnostic(ICommandContext commandContext) protected override Task RunInternalAsync(StringBuilder log, IList additionalFiles) { - log.AppendLine($"ICredentialStore instance is of type: {CommandContext.CredentialStore.GetType().Name}"); + log.AppendLine($"ICredentialStore instance is: {CommandContext.CredentialStore}"); + log.AppendLine($"CanStorePasswordExpiry: {CommandContext.CredentialStore.CanStorePasswordExpiry}"); + log.AppendLine($"CanStoreOAuthRefreshToken: {CommandContext.CredentialStore.CanStoreOAuthRefreshToken}"); // Create a service that is guaranteed to be unique string service = $"https://example.com/{Guid.NewGuid():N}"; const string account = "john.doe"; const string password = "letmein123"; // [SuppressMessage("Microsoft.Security", "CS001:SecretInline", Justification="Fake credential")] + var credential = new GitCredential(account, password) { + OAuthRefreshToken = "xyzzy", + PasswordExpiry = DateTimeOffset.FromUnixTimeSeconds(2147482647), + }; try { log.Append("Writing test credential..."); - CommandContext.CredentialStore.AddOrUpdate(service, account, password); + CommandContext.CredentialStore.AddOrUpdate(service, credential); log.AppendLine(" OK"); log.Append("Reading test credential..."); @@ -52,11 +58,27 @@ protected override Task RunInternalAsync(StringBuilder log, IList log.AppendLine($"Actual: {outCredential.Password}"); return Task.FromResult(false); } + + if (CommandContext.CredentialStore.CanStorePasswordExpiry && !StringComparer.Ordinal.Equals(credential.PasswordExpiry, outCredential.PasswordExpiry)) + { + log.Append("Test credential password_expiry_utc did not match!"); + log.AppendLine($"Expected: {credential.PasswordExpiry}"); + log.AppendLine($"Actual: {outCredential.PasswordExpiry}"); + return Task.FromResult(false); + } + + if (CommandContext.CredentialStore.CanStoreOAuthRefreshToken && !StringComparer.Ordinal.Equals(credential.OAuthRefreshToken, outCredential.OAuthRefreshToken)) + { + log.Append("Test credential oauth_refresh_token did not match!"); + log.AppendLine($"Expected: {credential.OAuthRefreshToken}"); + log.AppendLine($"Actual: {outCredential.OAuthRefreshToken}"); + return Task.FromResult(false); + } } finally { log.Append("Deleting test credential..."); - CommandContext.CredentialStore.Remove(service, account); + CommandContext.CredentialStore.Remove(service, credential); log.AppendLine(" OK"); } diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 447e465d57..a093b453a0 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -76,7 +76,7 @@ public override async Task GenerateCredentialAsync(InputArguments i Context.Trace.WriteLine($"\tUseAuthHeader = {oauthConfig.UseAuthHeader}"); Context.Trace.WriteLine($"\tDefaultUserName = {oauthConfig.DefaultUserName}"); - return await GetOAuthAccessToken(uri, input.UserName, oauthConfig, Context.Trace2); + return await GetOAuthAccessToken(uri, input.UserName, input.OAuthRefreshToken, oauthConfig, Context.Trace2); } // Try detecting WIA for this remote, if permitted else if (IsWindowsAuthAllowed) @@ -114,7 +114,7 @@ public override async Task GenerateCredentialAsync(InputArguments i return await _basicAuth.GetCredentialsAsync(uri.AbsoluteUri, input.UserName); } - private async Task GetOAuthAccessToken(Uri remoteUri, string userName, GenericOAuthConfig config, ITrace2 trace2) + private async Task GetOAuthAccessToken(Uri remoteUri, string userName, string refreshToken, GenericOAuthConfig config, ITrace2 trace2) { // TODO: Determined user info from a webcall? ID token? Need OIDC support string oauthUser = userName ?? config.DefaultUserName; @@ -128,33 +128,19 @@ private async Task GetOAuthAccessToken(Uri remoteUri, string userNa config.ClientSecret, config.UseAuthHeader); - // - // Prepend "refresh_token" to the hostname to get a (hopefully) unique service name that - // doesn't clash with an existing credential service. - // - // Appending "/refresh_token" to the end of the remote URI may not always result in a unique - // service because users may set credential.useHttpPath and include "/refresh_token" as a - // path name. - // - string refreshService = new UriBuilder(remoteUri) { Host = $"refresh_token.{remoteUri.Host}" } - .Uri.AbsoluteUri.TrimEnd('/'); - - // Try to use a refresh token if we have one - ICredential refreshToken = Context.CredentialStore.Get(refreshService, userName); + if (!Context.CredentialStore.CanStoreOAuthRefreshToken) { + var refreshService = GetRefreshTokenServiceName(remoteUri); + refreshToken ??= Context.CredentialStore.Get(refreshService, userName)?.OAuthRefreshToken; + } if (refreshToken != null) { + Context.Trace.WriteLine("Refreshing OAuth token"); try { - var refreshResult = await client.GetTokenByRefreshTokenAsync(refreshToken.Password, CancellationToken.None); - - // Store new refresh token if we have been given one - if (!string.IsNullOrWhiteSpace(refreshResult.RefreshToken)) - { - Context.CredentialStore.AddOrUpdate(refreshService, refreshToken.Account, refreshToken.Password); - } + var refreshResult = await client.GetTokenByRefreshTokenAsync(refreshToken, CancellationToken.None); // Return the new access token - return new GitCredential(oauthUser,refreshResult.AccessToken); + return new GitCredential(refreshResult, oauthUser); } catch (OAuth2Exception ex) { @@ -207,13 +193,25 @@ private async Task GetOAuthAccessToken(Uri remoteUri, string userNa throw new Trace2Exception(Context.Trace2, "No authentication mode selected!"); } - // Store the refresh token if we have one - if (!string.IsNullOrWhiteSpace(tokenResult.RefreshToken)) + return new GitCredential(tokenResult, oauthUser); + } + + public override Task EraseCredentialAsync(InputArguments input) + { + // delete any refresh token too + Context.CredentialStore.Remove(GetRefreshTokenServiceName(input.GetRemoteUri()), "oauth2"); + return base.EraseCredentialAsync(input); + } + + public override Task StoreCredentialAsync(InputArguments input) + { + if (!Context.CredentialStore.CanStoreOAuthRefreshToken && input.OAuthRefreshToken != null) { - Context.CredentialStore.AddOrUpdate(refreshService, oauthUser, tokenResult.RefreshToken); + var refreshService = GetRefreshTokenServiceName(input.GetRemoteUri()); + Context.Trace.WriteLine($"Storing refresh token separately under service {refreshService}..."); + Context.CredentialStore.AddOrUpdate(refreshService, new GitCredential("oauth2", input.OAuthRefreshToken)); } - - return new GitCredential(oauthUser, tokenResult.AccessToken); + return base.StoreCredentialAsync(input); } /// @@ -241,6 +239,19 @@ private bool IsWindowsAuthAllowed } } + private string GetRefreshTokenServiceName(Uri uri) + { + // Prepend "refresh_token" to the hostname to get a (hopefully) unique service name that + // doesn't clash with an existing credential service. + // + // Appending "/refresh_token" to the end of the remote URI may not always result in a unique + // service because users may set credential.useHttpPath and include "/refresh_token" as a + // path name. + // + return new UriBuilder(uri) { Host = $"refresh_token.{uri.Host}" } + .Uri.AbsoluteUri.TrimEnd('/'); + } + private HttpClient _httpClient; private HttpClient HttpClient => _httpClient ?? (_httpClient = Context.HttpClientFactory.CreateClient()); diff --git a/src/shared/Core/HostProvider.cs b/src/shared/Core/HostProvider.cs index 438053bcba..07abf25511 100644 --- a/src/shared/Core/HostProvider.cs +++ b/src/shared/Core/HostProvider.cs @@ -58,6 +58,9 @@ public interface IHostProvider : IDisposable /// /// Input arguments of a Git credential query. Task EraseCredentialAsync(InputArguments input); + + Task ValidateCredentialAsync(Uri remoteUri, ICredential credential) + => Task.FromResult(credential.PasswordExpiry == null || credential.PasswordExpiry >= DateTimeOffset.UtcNow); } /// @@ -125,24 +128,50 @@ public virtual async Task GetCredentialAsync(InputArguments input) string service = GetServiceName(input); Context.Trace.WriteLine($"Looking for existing credential in store with service={service} account={input.UserName}..."); - ICredential credential = Context.CredentialStore.Get(service, input.UserName); - if (credential == null) + // Query for matching credentials + ICredential credential = null; + while (true) { - Context.Trace.WriteLine("No existing credentials found."); - - // No existing credential was found, create a new one - Context.Trace.WriteLine("Creating new credential..."); - credential = await GenerateCredentialAsync(input); - Context.Trace.WriteLine("Credential created."); + Context.Trace.WriteLine("Querying for existing credentials..."); + credential = Context.CredentialStore.Get(service, input.UserName); + if (credential == null) + { + Context.Trace.WriteLine("No existing credentials found."); + break; + } + else + { + Context.Trace.WriteLine("Existing credential found."); + if (await ValidateCredentialAsync(input.GetRemoteUri(), credential)) + { + Context.Trace.WriteLine("Existing credential satisfies validation."); + return credential; + } + else + { + Context.Trace.WriteLine("Existing credential fails validation."); + if (credential.OAuthRefreshToken != null) + { + Context.Trace.WriteLine("Found OAuth refresh token."); + input = new InputArguments(input, credential.OAuthRefreshToken); + } + Context.Trace.WriteLine("Erasing invalid credential..."); + // Why necessary to erase? We can't be sure that storing a fresh + // credential will overwrite the invalid credential, particularly + // if the usernames differ. + Context.CredentialStore.Remove(service, credential); + } + } } - else - { - Context.Trace.WriteLine("Existing credential found."); - } - + Context.Trace.WriteLine("Creating new credential..."); + credential = await GenerateCredentialAsync(input); + Context.Trace.WriteLine("Credential created."); return credential; } + public virtual Task ValidateCredentialAsync(Uri remoteUri, ICredential credential) + => Task.FromResult(credential.PasswordExpiry == null || credential.PasswordExpiry >= DateTimeOffset.UtcNow); + public virtual Task StoreCredentialAsync(InputArguments input) { string service = GetServiceName(input); @@ -158,7 +187,7 @@ public virtual Task StoreCredentialAsync(InputArguments input) { // Add or update the credential in the store. Context.Trace.WriteLine($"Storing credential with service={service} account={input.UserName}..."); - Context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); + Context.CredentialStore.AddOrUpdate(service, new GitCredential(input)); Context.Trace.WriteLine("Credential was successfully stored."); } @@ -171,7 +200,7 @@ public virtual Task EraseCredentialAsync(InputArguments input) // Try to locate an existing credential Context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={input.UserName}..."); - if (Context.CredentialStore.Remove(service, input.UserName)) + if (Context.CredentialStore.Remove(service, new GitCredential(input))) { Context.Trace.WriteLine("Credential was successfully erased."); } diff --git a/src/shared/Core/ICredentialStore.cs b/src/shared/Core/ICredentialStore.cs index e5c40060e2..12c8d90f8c 100644 --- a/src/shared/Core/ICredentialStore.cs +++ b/src/shared/Core/ICredentialStore.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace GitCredentialManager @@ -28,14 +29,25 @@ public interface ICredentialStore /// Name of the service this credential is for. Use null to match all values. /// Account associated with this credential. Use null to match all values. /// Secret value to store. +// [Obsolete("Prefer AddOrUpdate(string, ICredential)")] void AddOrUpdate(string service, string account, string secret); + void AddOrUpdate(string service, ICredential credential) + => AddOrUpdate(service, credential.Account, credential.Password); + /// /// Delete credential from the store that matches the given query. /// /// Name of the service to match against. Use null to match all values. /// Account name to match against. Use null to match all values. /// True if the credential was deleted, false otherwise. +// [Obsolete("Prefer Remove(string, ICredential)")] bool Remove(string service, string account); + + bool Remove(string service, ICredential credential) + => Remove(service, credential.Account); + + bool CanStorePasswordExpiry => false; + bool CanStoreOAuthRefreshToken => false; } } diff --git a/src/shared/Core/InputArguments.cs b/src/shared/Core/InputArguments.cs index 626fc805d1..743f824ae4 100644 --- a/src/shared/Core/InputArguments.cs +++ b/src/shared/Core/InputArguments.cs @@ -35,6 +35,16 @@ public InputArguments(IDictionary> dict) _dict = new ReadOnlyDictionary>(dict); } + /// + /// Return a copy of input, with additional OAuth refresh token. + /// + public InputArguments(InputArguments input, string oauthRefreshToken) { + _dict = new Dictionary>(input._dict) + { + ["oauth_refresh_token"] = new List() { oauthRefreshToken } + }; + } + #region Common Arguments public string Protocol => GetArgumentOrDefault("protocol"); @@ -42,6 +52,8 @@ public InputArguments(IDictionary> dict) public string Path => GetArgumentOrDefault("path"); public string UserName => GetArgumentOrDefault("username"); public string Password => GetArgumentOrDefault("password"); + public string OAuthRefreshToken => GetArgumentOrDefault("oauth_refresh_token"); + public string PasswordExpiry => GetArgumentOrDefault("password_expiry_utc"); public IList WwwAuth => GetMultiArgumentOrDefault("wwwauth"); #endregion diff --git a/src/shared/Core/Interop/Linux/SecretServiceCollection.cs b/src/shared/Core/Interop/Linux/SecretServiceCollection.cs index 093baf5c3c..3a99d6171b 100644 --- a/src/shared/Core/Interop/Linux/SecretServiceCollection.cs +++ b/src/shared/Core/Interop/Linux/SecretServiceCollection.cs @@ -126,10 +126,21 @@ out error } public unsafe void AddOrUpdate(string service, string account, string secret) + => AddOrUpdate(service, new GitCredential(account, secret)); + + public unsafe void AddOrUpdate(string service, ICredential credential) { GHashTable* attributes = null; SecretValue* secretValue = null; GError *error = null; + var account = credential.Account; + var secret = credential.Password; + if (credential.OAuthRefreshToken != null) { + secret += "\noauth_refresh_token=" + credential.OAuthRefreshToken; + } + if (credential.PasswordExpiry.HasValue) { + secret += "\npassword_expiry_utc=" + credential.PasswordExpiry.Value.ToUnixTimeSeconds(); + } // If there is an existing credential that matches the same account and password // then don't bother writing out anything because they're the same! @@ -271,7 +282,7 @@ private static unsafe ICredential CreateCredentialFromItem(SecretItem* item) IntPtr serviceKeyPtr = IntPtr.Zero; IntPtr accountKeyPtr = IntPtr.Zero; SecretValue* value = null; - IntPtr passwordPtr = IntPtr.Zero; + IntPtr secretPtr = IntPtr.Zero; GError* error = null; try @@ -297,10 +308,27 @@ private static unsafe ICredential CreateCredentialFromItem(SecretItem* item) } // Extract the secret/password - passwordPtr = secret_value_get(value, out int passwordLength); - string password = Marshal.PtrToStringAuto(passwordPtr, passwordLength); + secretPtr = secret_value_get(value, out int passwordLength); + string secret = Marshal.PtrToStringAuto(secretPtr, passwordLength); + var lines = secret.Split('\n'); + var password = lines[0]; + string oauth_refresh_token = null; + DateTimeOffset? password_expiry_utc = null; + for(int i = 1; i < lines.Length; i++) { + var parts = lines[i].Split('=', 2); + if (parts.Length != 2) + continue; + if (parts[0] == "oauth_refresh_token") + oauth_refresh_token = parts[1]; + if (parts[0] == "password_expiry_utc" && long.TryParse(parts[1], out long x)) + password_expiry_utc = DateTimeOffset.FromUnixTimeSeconds(x); + } - return new SecretServiceCredential(service, account, password); + return new SecretServiceCredential(service, account, password) + { + OAuthRefreshToken = oauth_refresh_token, + PasswordExpiry = password_expiry_utc, + }; } finally { @@ -366,5 +394,8 @@ private static SecretSchema GetSchema() return schema; } + + public bool CanStorePasswordExpiry => true; + public bool CanStoreOAuthRefreshToken => true; } } diff --git a/src/shared/Core/Interop/Linux/SecretServiceCredential.cs b/src/shared/Core/Interop/Linux/SecretServiceCredential.cs index c8956aaed4..9b0f8e5fa7 100644 --- a/src/shared/Core/Interop/Linux/SecretServiceCredential.cs +++ b/src/shared/Core/Interop/Linux/SecretServiceCredential.cs @@ -1,9 +1,9 @@ +using System; using System.Diagnostics; namespace GitCredentialManager.Interop.Linux { - [DebuggerDisplay("{DebuggerDisplay}")] - public class SecretServiceCredential : ICredential + public record SecretServiceCredential : ICredential { internal SecretServiceCredential(string service, string account, string password) { @@ -18,6 +18,8 @@ internal SecretServiceCredential(string service, string account, string password public string Password { get; } - private string DebuggerDisplay => $"[Service: {Service}, Account: {Account}]"; + public string OAuthRefreshToken { get; init; } + + public DateTimeOffset? PasswordExpiry { get; init; } } } diff --git a/src/shared/Core/Interop/Windows/WindowsCredential.cs b/src/shared/Core/Interop/Windows/WindowsCredential.cs index 6691c709bb..9e442de607 100644 --- a/src/shared/Core/Interop/Windows/WindowsCredential.cs +++ b/src/shared/Core/Interop/Windows/WindowsCredential.cs @@ -1,7 +1,7 @@ namespace GitCredentialManager.Interop.Windows { - public class WindowsCredential : ICredential + public record WindowsCredential : ICredential { public WindowsCredential(string service, string userName, string password, string targetName) { @@ -19,6 +19,8 @@ public WindowsCredential(string service, string userName, string password, strin public string TargetName { get; } + public string OAuthRefreshToken { get; init; } + string ICredential.Account => UserName; } } diff --git a/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs b/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs index f577ad3010..ca795104d1 100644 --- a/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs +++ b/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs @@ -34,10 +34,18 @@ public ICredential Get(string service, string account) return Enumerate(service, account).FirstOrDefault(); } - public void AddOrUpdate(string service, string account, string secret) + public void AddOrUpdate(string service, string account, string password) + => AddOrUpdate(service, new GitCredential(account, password)); + + public void AddOrUpdate(string service, ICredential credential) { EnsureArgument.NotNullOrWhiteSpace(service, nameof(service)); + var account = credential.Account; + var secret = credential.Password; + if (!string.IsNullOrEmpty(credential.OAuthRefreshToken)) + secret += "\noauth_refresh_token=" + credential.OAuthRefreshToken; + IntPtr existingCredPtr = IntPtr.Zero; IntPtr credBlob = IntPtr.Zero; @@ -88,6 +96,7 @@ public void AddOrUpdate(string service, string account, string secret) CredentialBlob = credBlob, Persist = CredentialPersist.LocalMachine, UserName = account, + // TODO: save password expiry in attribute }; int result = Win32Error.GetLastError( @@ -211,7 +220,17 @@ private IEnumerable Enumerate(string service, string account) private WindowsCredential CreateCredentialFromStructure(Win32Credential credential) { - string password = credential.GetCredentialBlobAsString(); + string secret = credential.GetCredentialBlobAsString(); + var lines = secret.Split('\n'); + var password = lines[0]; + string oauth_refresh_token = null; + for(int i = 1; i < lines.Length; i++) { + var parts = lines[i].Split('=', 2); + if (parts.Length != 2) + continue; + if (parts[0] == "oauth_refresh_token") + oauth_refresh_token = parts[1]; + } // Recover the target name we gave from the internal (raw) target name string targetName = credential.TargetName.TrimUntilIndexOf(TargetNameLegacyGenericPrefix); @@ -226,7 +245,17 @@ private WindowsCredential CreateCredentialFromStructure(Win32Credential credenti // Strip any userinfo component from the service name serviceName = RemoveUriUserInfo(serviceName); - return new WindowsCredential(serviceName, credential.UserName, password, targetName); + // TODO: read password_expiry_utc from attribute + // int ptrSize = Marshal.SizeOf(); + // for (int i = 0; i < credential.AttributeCount; i++) + // { + // IntPtr attrPtr = Marshal.ReadIntPtr(credential.Attributes, i * ptrSize); + // Win32CredentialAttribute attr = Marshal.PtrToStructure(attrPtr); + // } + + return new WindowsCredential(serviceName, credential.UserName, password, targetName) { + OAuthRefreshToken = oauth_refresh_token, + }; } public /* for testing */ static string RemoveUriUserInfo(string url) @@ -371,5 +400,7 @@ private WindowsCredential CreateCredentialFromStructure(Win32Credential credenti return sb.ToString(); } + + public bool CanStoreOAuthRefreshToken => true; } } diff --git a/src/shared/Core/StreamExtensions.cs b/src/shared/Core/StreamExtensions.cs index 7ff338f5ab..d4012e1e62 100644 --- a/src/shared/Core/StreamExtensions.cs +++ b/src/shared/Core/StreamExtensions.cs @@ -206,6 +206,9 @@ public static async Task WriteDictionaryAsync(this TextWriter writer, IDictionar { foreach (var kvp in dict) { + if (kvp.Value == null) { + continue; + } await writer.WriteLineAsync($"{kvp.Key}={kvp.Value}"); } diff --git a/src/shared/GitLab/GitLabHostProvider.cs b/src/shared/GitLab/GitLabHostProvider.cs index eda6e2f0f8..2b1a216f5e 100644 --- a/src/shared/GitLab/GitLabHostProvider.cs +++ b/src/shared/GitLab/GitLabHostProvider.cs @@ -175,50 +175,14 @@ internal AuthenticationModes GetSupportedAuthenticationModes(Uri targetUri) return modes; } - // Stores OAuth tokens as a side effect - public override async Task GetCredentialAsync(InputArguments input) + public override async Task ValidateCredentialAsync(Uri remoteUri, ICredential credential) { - string service = GetServiceName(input); - ICredential credential = Context.CredentialStore.Get(service, input.UserName); - if (credential?.Account == "oauth2" && await IsOAuthTokenExpired(input.GetRemoteUri(), credential.Password)) - { - Context.Trace.WriteLine("Removing expired OAuth access token..."); - Context.CredentialStore.Remove(service, credential.Account); - credential = null; - } - - if (credential != null) - { - return credential; - } - - string refreshService = GetRefreshTokenServiceName(input); - string refreshToken = Context.CredentialStore.Get(refreshService, input.UserName)?.Password; - if (refreshToken != null) - { - Context.Trace.WriteLine("Refreshing OAuth token..."); - try - { - credential = await RefreshOAuthCredentialAsync(input, refreshToken); - } - catch (Exception e) - { - Context.Terminal.WriteLine($"OAuth token refresh failed: {e.Message}"); - } - } - - credential ??= await GenerateCredentialAsync(input); - - if (credential is OAuthCredential oAuthCredential) - { - Context.Trace.WriteLine("Pre-emptively storing OAuth access and refresh tokens..."); - // freshly-generated OAuth credential - // store credential, since we know it to be valid (whereas Git will only store credential if git push succeeds) - Context.CredentialStore.AddOrUpdate(service, oAuthCredential.Account, oAuthCredential.AccessToken); - // store refresh token under a separate service - Context.CredentialStore.AddOrUpdate(refreshService, oAuthCredential.Account, oAuthCredential.RefreshToken); - } - return credential; + if (credential.PasswordExpiry.HasValue) + return await base.ValidateCredentialAsync(remoteUri, credential); + else if (credential.Account == "oauth2") + return !await IsOAuthTokenExpired(remoteUri, credential.Password); + else + return true; } private async Task IsOAuthTokenExpired(Uri baseUri, string accessToken) @@ -243,31 +207,16 @@ private async Task IsOAuthTokenExpired(Uri baseUri, string accessToken) } } - internal class OAuthCredential : ICredential - { - public OAuthCredential(OAuth2TokenResult oAuth2TokenResult) - { - AccessToken = oAuth2TokenResult.AccessToken; - RefreshToken = oAuth2TokenResult.RefreshToken; - } - - // username must be 'oauth2' https://docs.gitlab.com/ee/api/oauth2.html#access-git-over-https-with-access-token - public string Account => "oauth2"; - public string AccessToken { get; } - public string RefreshToken { get; } - string ICredential.Password => AccessToken; - } - - private async Task GenerateOAuthCredentialAsync(InputArguments input) + private async Task GenerateOAuthCredentialAsync(InputArguments input) { OAuth2TokenResult result = await _gitLabAuth.GetOAuthTokenViaBrowserAsync(input.GetRemoteUri(), GitLabOAuthScopes); - return new OAuthCredential(result); + return new GitCredential(result, "oauth2"); } - private async Task RefreshOAuthCredentialAsync(InputArguments input, string refreshToken) + private async Task RefreshOAuthCredentialAsync(InputArguments input, string refreshToken) { OAuth2TokenResult result = await _gitLabAuth.GetOAuthTokenViaRefresh(input.GetRemoteUri(), refreshToken); - return new OAuthCredential(result); + return new GitCredential(result, "oauth2"); } protected override void ReleaseManagedResources() @@ -276,9 +225,9 @@ protected override void ReleaseManagedResources() base.ReleaseManagedResources(); } - private string GetRefreshTokenServiceName(InputArguments input) + private string GetRefreshTokenServiceName(Uri remoteUri) { - var builder = new UriBuilder(GetServiceName(input)); + var builder = new UriBuilder(); builder.Host = "oauth-refresh-token." + builder.Host; return builder.Uri.ToString(); } @@ -286,8 +235,18 @@ private string GetRefreshTokenServiceName(InputArguments input) public override Task EraseCredentialAsync(InputArguments input) { // delete any refresh token too - Context.CredentialStore.Remove(GetRefreshTokenServiceName(input), "oauth2"); + Context.CredentialStore.Remove(GetRefreshTokenServiceName(input.GetRemoteUri()), "oauth2"); return base.EraseCredentialAsync(input); } + + public override Task StoreCredentialAsync(InputArguments input) + { + if (!Context.CredentialStore.CanStoreOAuthRefreshToken && input.OAuthRefreshToken != null) { + var refreshService = GetRefreshTokenServiceName(input.GetRemoteUri()); + Context.Trace.WriteLine($"Storing refresh token separately under service {refreshService}..."); + Context.CredentialStore.AddOrUpdate(refreshService, new GitCredential("oauth2", input.OAuthRefreshToken)); + } + return base.StoreCredentialAsync(input); + } } } diff --git a/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs b/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs index 6ef1e18667..2d61717fde 100644 --- a/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs +++ b/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -93,7 +94,7 @@ private IEnumerable Query(string service, string account) } } - public class TestCredential : ICredential + public record TestCredential : ICredential { public TestCredential(string service, string account, string password) { @@ -107,5 +108,9 @@ public TestCredential(string service, string account, string password) public string Account { get; } public string Password { get; } + + public DateTimeOffset? PasswordExpiry { get; init; } + + public string OAuthRefreshToken { get; init; } } } diff --git a/src/shared/TestInfrastructure/Objects/TestHostProvider.cs b/src/shared/TestInfrastructure/Objects/TestHostProvider.cs index a1a211bc68..6c10fed9b9 100644 --- a/src/shared/TestInfrastructure/Objects/TestHostProvider.cs +++ b/src/shared/TestInfrastructure/Objects/TestHostProvider.cs @@ -14,6 +14,8 @@ public TestHostProvider(ICommandContext context) public Func GenerateCredentialFunc { get; set; } + public Func ValidateCredentialFunc { get; set; } + #region HostProvider public override string Id { get; } = "test-provider"; @@ -29,6 +31,9 @@ public override Task GenerateCredentialAsync(InputArguments input) return Task.FromResult(GenerateCredentialFunc(input)); } + public override Task ValidateCredentialAsync(Uri remoteUri, ICredential credential) + => ValidateCredentialFunc != null ? Task.FromResult(ValidateCredentialFunc(remoteUri, credential)) : base.ValidateCredentialAsync(remoteUri, credential); + #endregion } }