From 83c3f3a29e77c71c94e6afe07b494b6be5231e15 Mon Sep 17 00:00:00 2001 From: M Hickford Date: Thu, 26 Oct 2023 08:16:06 +0100 Subject: [PATCH] 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. --- .../Core.Tests/Commands/GetCommandTests.cs | 12 ++- .../Core.Tests/Commands/StoreCommandTests.cs | 12 ++- .../Core.Tests/GenericHostProviderTests.cs | 8 +- src/shared/Core.Tests/HostProviderTests.cs | 67 ++++++++++++-- .../Linux/SecretServiceCollectionTests.cs | 6 +- .../Windows/WindowsCredentialManagerTests.cs | 4 +- src/shared/Core/Commands/GetCommand.cs | 6 +- src/shared/Core/Commands/GitCommandBase.cs | 2 +- src/shared/Core/Credential.cs | 45 +++++++++- src/shared/Core/CredentialCacheStore.cs | 52 ++++++++--- src/shared/Core/CredentialStore.cs | 38 +++++++- .../Diagnostics/CredentialStoreDiagnostic.cs | 28 +++++- src/shared/Core/GenericHostProvider.cs | 67 ++++++++------ src/shared/Core/HostProvider.cs | 59 ++++++++---- src/shared/Core/ICredentialStore.cs | 12 +++ src/shared/Core/InputArguments.cs | 12 +++ .../Interop/Linux/SecretServiceCollection.cs | 39 +++++++- .../Interop/Linux/SecretServiceCredential.cs | 8 +- .../Core/Interop/Windows/WindowsCredential.cs | 4 +- .../Windows/WindowsCredentialManager.cs | 37 +++++++- src/shared/Core/StreamExtensions.cs | 3 + src/shared/GitLab/GitLabHostProvider.cs | 89 +++++-------------- .../Objects/TestCredentialStore.cs | 7 +- .../Objects/TestHostProvider.cs | 5 ++ 24 files changed, 465 insertions(+), 157 deletions(-) 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 } }