From 01fcd911a959a125d1423f26cf7a6215d8d2477d 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. --- .../BitbucketHostProviderTest.cs | 28 ++--- .../BitbucketHostProvider.cs | 46 +++++--- .../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 | 48 +++++++- src/shared/Core/CredentialCacheStore.cs | 52 +++++++-- src/shared/Core/CredentialStore.cs | 35 ++++++ .../Diagnostics/CredentialStoreDiagnostic.cs | 28 ++++- src/shared/Core/FileCredential.cs | 6 + src/shared/Core/GenericHostProvider.cs | 67 ++++++----- src/shared/Core/HostProvider.cs | 61 +++++++--- src/shared/Core/ICredentialStore.cs | 10 ++ src/shared/Core/InputArguments.cs | 12 ++ .../Interop/Linux/SecretServiceCollection.cs | 41 ++++++- .../Interop/Linux/SecretServiceCredential.cs | 6 +- .../Core/Interop/MacOS/MacOSKeychain.cs | 7 ++ .../Interop/MacOS/MacOSKeychainCredential.cs | 5 + .../Core/Interop/Windows/WindowsCredential.cs | 6 + .../Windows/WindowsCredentialManager.cs | 35 +++++- src/shared/Core/NullCredentialStore.cs | 6 + src/shared/Core/PlaintextCredentialStore.cs | 7 ++ src/shared/Core/StreamExtensions.cs | 3 + .../GitHub/GitHubHostProvider.Commands.cs | 2 +- src/shared/GitHub/GitHubHostProvider.cs | 6 +- .../GitLab.Tests/GitLabHostProviderTests.cs | 6 + src/shared/GitLab/GitLabHostProvider.cs | 110 +++++++----------- .../AzureReposHostProvider.cs | 2 + .../Objects/TestCredentialStore.cs | 22 ++++ .../Objects/TestHostProvider.cs | 5 + 35 files changed, 581 insertions(+), 198 deletions(-) diff --git a/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs index 36b116615..561cf8f0e 100644 --- a/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs +++ b/src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs @@ -221,11 +221,11 @@ public async Task BitbucketHostProvider_GetCredentialAsync_Valid_New_OAuth( Assert.Equal(username, credential.Account); Assert.Equal(accessToken, credential.Password); + Assert.Equal(refreshToken, credential.OAuthRefreshToken); VerifyInteractiveAuthRan(input); VerifyOAuthFlowRan(input, accessToken); VerifyValidateAccessTokenRan(input, accessToken); - VerifyOAuthRefreshTokenStored(context, input, refreshToken); } [Theory] @@ -234,12 +234,12 @@ public async Task BitbucketHostProvider_GetCredentialAsync_Valid_New_OAuth( public async Task BitbucketHostProvider_GetCredentialAsync_MissingAT_OAuth_Refresh( string protocol, string host, string username, string refreshToken, string accessToken) { - var input = MockInput(protocol, host, username); + var input = MockInput(protocol, host, username, refreshToken); var context = new TestCommandContext(); // AT has does not exist, but RT is still valid - MockStoredRefreshToken(context, input, refreshToken); + MockStoredAccount(context, input, null); MockRemoteAccessTokenValid(input, accessToken); MockRemoteRefreshTokenValid(input, refreshToken, accessToken); @@ -261,15 +261,13 @@ public async Task BitbucketHostProvider_GetCredentialAsync_MissingAT_OAuth_Refre public async Task BitbucketHostProvider_GetCredentialAsync_ExpiredAT_OAuth_Refresh( string protocol, string host, string username, string refreshToken, string expiredAccessToken, string accessToken) { - var input = MockInput(protocol, host, username); + var input = MockInput(protocol, host, username, refreshToken); var context = new TestCommandContext(); // AT exists but has expired, but RT is still valid MockStoredAccount(context, input, expiredAccessToken); MockRemoteAccessTokenExpired(input, expiredAccessToken); - - MockStoredRefreshToken(context, input, refreshToken); MockRemoteAccessTokenValid(input, accessToken); MockRemoteRefreshTokenValid(input, refreshToken, accessToken); @@ -291,13 +289,12 @@ public async Task BitbucketHostProvider_GetCredentialAsync_ExpiredAT_OAuth_Refre public async Task BitbucketHostProvider_GetCredentialAsync_PreconfiguredMode_OAuth_ValidRT_IsRespected( string protocol, string host, string username, string refreshToken, string accessToken) { - var input = MockInput(protocol, host, username); + var input = MockInput(protocol, host, username, refreshToken); var context = new TestCommandContext(); context.Environment.Variables.Add(BitbucketConstants.EnvironmentVariables.AuthenticationModes, "oauth"); // We have a stored RT so we can just use that without any prompts - MockStoredRefreshToken(context, input, refreshToken); MockRemoteAccessTokenValid(input, accessToken); MockRemoteRefreshTokenValid(input, refreshToken, accessToken); @@ -316,7 +313,7 @@ public async Task BitbucketHostProvider_GetCredentialAsync_PreconfiguredMode_OAu public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredentials_OAuth_IsRespected( string protocol, string host, string username, string storedToken, string newToken, string refreshToken) { - var input = MockInput(protocol, host, username); + var input = MockInput(protocol, host, username, refreshToken); var context = new TestCommandContext(); context.Environment.Variables.Add( @@ -324,7 +321,6 @@ public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredenti // User has stored access token that we shouldn't use - RT should be used to mint new AT MockStoredAccount(context, input, storedToken); - MockStoredRefreshToken(context, input, refreshToken); MockRemoteAccessTokenValid(input, newToken); MockRemoteRefreshTokenValid(input, refreshToken, newToken); @@ -437,13 +433,14 @@ public async Task BitbucketHostProvider_EraseCredentialAsync(string protocol, st #region Test helpers - private static InputArguments MockInput(string protocol, string host, string username) + private static InputArguments MockInput(string protocol, string host, string username, string refreshToken = null) { return new InputArguments(new Dictionary { ["protocol"] = protocol, ["host"] = host, - ["username"] = username + ["username"] = username, + ["oauth_refresh_token"] = refreshToken, }); } @@ -551,13 +548,6 @@ private static void MockStoredAccount(TestCommandContext context, InputArguments context.CredentialStore.Add(remoteUrl, new TestCredential(input.Host, input.UserName, password)); } - private static void MockStoredRefreshToken(TestCommandContext context, InputArguments input, string token) - { - var remoteUri = input.GetRemoteUri(); - var refreshService = BitbucketHostProvider.GetRefreshTokenServiceName(remoteUri); - context.CredentialStore.Add(refreshService, new TestCredential(refreshService, input.UserName, token)); - } - private void MockRemoteOAuthTokenCreate(InputArguments input, string accessToken, string refreshToken) { bitbucketAuthentication.Setup(x => x.CreateOAuthCredentialsAsync(input)) diff --git a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs index 286398de9..76937ee50 100644 --- a/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs +++ b/src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs @@ -9,6 +9,7 @@ namespace Atlassian.Bitbucket { + // TODO: simplify and inherit from HostProvider public class BitbucketHostProvider : IHostProvider { private readonly ICommandContext _context; @@ -139,10 +140,11 @@ private async Task GetRefreshedCredentials(InputArguments input, Au var refreshTokenService = GetRefreshTokenServiceName(remoteUri); _context.Trace.WriteLine("Checking for refresh token..."); - ICredential refreshToken = SupportsOAuth(authModes) - ? _context.CredentialStore.Get(refreshTokenService, input.UserName) - : null; - + string refreshToken = input.OAuthRefreshToken; + if (!_context.CredentialStore.CanStoreOAuthRefreshToken && SupportsOAuth(authModes)) { + refreshToken ??= _context.CredentialStore.Get(refreshTokenService, input.UserName)?.Password; + } + if (refreshToken is null) { _context.Trace.WriteLine("No stored refresh token found"); @@ -199,26 +201,28 @@ private async Task GetRefreshedCredentials(InputArguments input, Au return await GetOAuthCredentialsViaInteractiveBrowserFlow(input); } - private async Task GetOAuthCredentialsViaRefreshFlow(InputArguments input, ICredential refreshToken) + private async Task GetOAuthCredentialsViaRefreshFlow(InputArguments input, string refreshToken) { Uri remoteUri = input.GetRemoteUri(); var refreshTokenService = GetRefreshTokenServiceName(remoteUri); _context.Trace.WriteLine("Refreshing OAuth credentials using refresh token..."); - OAuth2TokenResult refreshResult = await _bitbucketAuth.RefreshOAuthCredentialsAsync(input, refreshToken.Password); + OAuth2TokenResult oauthResult = await _bitbucketAuth.RefreshOAuthCredentialsAsync(input, refreshToken); // Resolve the username _context.Trace.WriteLine("Resolving username for refreshed OAuth credential..."); - string refreshUserName = await ResolveOAuthUserNameAsync(input, refreshResult.AccessToken); - _context.Trace.WriteLine($"Username for refreshed OAuth credential is '{refreshUserName}'"); + string newUserName = await ResolveOAuthUserNameAsync(input, oauthResult.AccessToken); + _context.Trace.WriteLine($"Username for refreshed OAuth credential is '{newUserName}'"); - // Store the refreshed RT - _context.Trace.WriteLine("Storing new refresh token..."); - _context.CredentialStore.AddOrUpdate(refreshTokenService, remoteUri.GetUserName(), refreshResult.RefreshToken); + if (!_context.CredentialStore.CanStoreOAuthRefreshToken) { + // Store the refreshed RT + _context.Trace.WriteLine("Storing new refresh token..."); + _context.CredentialStore.AddOrUpdate(refreshTokenService, remoteUri.GetUserName(), oauthResult.RefreshToken); + } // Return new access token - return new GitCredential(refreshUserName, refreshResult.AccessToken); + return new GitCredential(oauthResult, newUserName); } private async Task GetOAuthCredentialsViaInteractiveBrowserFlow(InputArguments input) @@ -239,13 +243,15 @@ private async Task GetOAuthCredentialsViaInteractiveBrowserFlow(Inp string newUserName = await ResolveOAuthUserNameAsync(input, oauthResult.AccessToken); _context.Trace.WriteLine($"Username for OAuth credential is '{newUserName}'"); - // Store the new RT - _context.Trace.WriteLine("Storing new refresh token..."); - _context.CredentialStore.AddOrUpdate(refreshTokenService, newUserName, oauthResult.RefreshToken); - _context.Trace.WriteLine("Refresh token was successfully stored."); + if (!_context.CredentialStore.CanStoreOAuthRefreshToken) { + // Store the new RT + _context.Trace.WriteLine("Storing new refresh token..."); + _context.CredentialStore.AddOrUpdate(refreshTokenService, newUserName, oauthResult.RefreshToken); + _context.Trace.WriteLine("Refresh token was successfully stored."); + } // Return the new AT as the credential - return new GitCredential(newUserName, oauthResult.AccessToken); + return new GitCredential(oauthResult, newUserName); } private static bool SupportsOAuth(AuthenticationModes authModes) @@ -333,7 +339,7 @@ public Task StoreCredentialAsync(InputArguments input) string service = GetServiceName(remoteUri); _context.Trace.WriteLine("Storing credential..."); - _context.CredentialStore.AddOrUpdate(service, input.UserName, input.Password); + _context.CredentialStore.AddOrUpdate(service, new GitCredential(input)); _context.Trace.WriteLine("Credential was successfully stored."); return Task.CompletedTask; @@ -450,7 +456,7 @@ private async Task ValidateCredentialsWork(InputArguments input, ICredenti return true; } - private static string GetServiceName(Uri remoteUri) + internal static string GetServiceName(Uri remoteUri) { return remoteUri.WithoutUserInfo().AbsoluteUri.TrimEnd('/'); } @@ -473,5 +479,7 @@ public void Dispose() _restApiRegistry.Dispose(); _bitbucketAuth.Dispose(); } + + public Task ValidateCredentialAsync(Uri remoteUri, ICredential credential) => Task.FromResult(credential.PasswordExpiry == null || credential.PasswordExpiry >= DateTimeOffset.UtcNow); } } diff --git a/src/shared/Core.Tests/Commands/GetCommandTests.cs b/src/shared/Core.Tests/Commands/GetCommandTests.cs index f80824794..ca4e31b22 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 e7cd4acad..a770f7099 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 8f9594b06..f8fb641f8 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 60d0cfba8..43cce222f 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 async Task HostProvider_ValidateCredentialAsync() + { + var context = new TestCommandContext(); + var provider = new TestHostProvider(context); + Assert.True(await provider.ValidateCredentialAsync(null, new GitCredential("username", "pass"))); + Assert.True(await provider.ValidateCredentialAsync(null, new GitCredential("username", "pass") {PasswordExpiry + = DateTimeOffset.UtcNow + TimeSpan.FromHours(1)})); + Assert.False(await provider.ValidateCredentialAsync(null, new GitCredential("username", "pass") {PasswordExpiry + = DateTimeOffset.UtcNow - TimeSpan.FromMinutes(1)})); + } + #endregion } } diff --git a/src/shared/Core.Tests/Interop/Linux/SecretServiceCollectionTests.cs b/src/shared/Core.Tests/Interop/Linux/SecretServiceCollectionTests.cs index 8cc6c7272..e34966b27 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 ba4659ec0..f033d345b 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 8cc1bff7d..dbd905793 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 b277d1a75..4d4ed4087 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 0a6130eae..2a1c39fed 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,6 +18,18 @@ 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; } + + /// + /// 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; } } /// @@ -28,8 +43,37 @@ public GitCredential(string userName, string password) Password = password; } - public string Account { get; } + public GitCredential(string account) + { + Account = account; + } + + 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; set; } + + public string Password { get; set; } - public string Password { get; } + public DateTimeOffset? PasswordExpiry { get; set; } + + public string OAuthRefreshToken { get; set; } } } diff --git a/src/shared/Core/CredentialCacheStore.cs b/src/shared/Core/CredentialCacheStore.cs index 41d3ffd3c..673ac508e 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)); 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 ?? false) { - 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)); + + public bool CanStorePasswordExpiry => true; + public bool CanStoreOAuthRefreshToken => true; } } diff --git a/src/shared/Core/CredentialStore.cs b/src/shared/Core/CredentialStore.cs index 11dc83818..0c89fffcc 100644 --- a/src/shared/Core/CredentialStore.cs +++ b/src/shared/Core/CredentialStore.cs @@ -49,6 +49,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() @@ -372,5 +384,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 74f9ca2ed..acd3e04cb 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/FileCredential.cs b/src/shared/Core/FileCredential.cs index 45a0188f0..a38bf7674 100644 --- a/src/shared/Core/FileCredential.cs +++ b/src/shared/Core/FileCredential.cs @@ -1,3 +1,5 @@ +using System; + namespace GitCredentialManager { public class FileCredential : ICredential @@ -17,5 +19,9 @@ public FileCredential(string fullPath, string service, string account, string pa public string Account { get; } public string Password { get; } + + public DateTimeOffset? PasswordExpiry => null; + + public string OAuthRefreshToken => null; } } diff --git a/src/shared/Core/GenericHostProvider.cs b/src/shared/Core/GenericHostProvider.cs index 9f087ca5b..807551edd 100644 --- a/src/shared/Core/GenericHostProvider.cs +++ b/src/shared/Core/GenericHostProvider.cs @@ -87,7 +87,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) @@ -125,7 +125,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; @@ -139,33 +139,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)?.Password; + } 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) { @@ -218,13 +204,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); } /// @@ -252,6 +250,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 438053bcb..5a469709e 100644 --- a/src/shared/Core/HostProvider.cs +++ b/src/shared/Core/HostProvider.cs @@ -58,6 +58,11 @@ public interface IHostProvider : IDisposable /// /// Input arguments of a Git credential query. Task EraseCredentialAsync(InputArguments input); + + /// + /// Validate the given credential. + /// + Task ValidateCredentialAsync(Uri remoteUri, ICredential credential); } /// @@ -125,24 +130,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 +189,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 +202,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 e5c40060e..d0a2eeab3 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,23 @@ 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); + /// /// 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); + + bool CanStorePasswordExpiry { get; } + bool CanStoreOAuthRefreshToken { get; } } } diff --git a/src/shared/Core/InputArguments.cs b/src/shared/Core/InputArguments.cs index 626fc805d..ae3cf290f 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.ToDictionary(p => p.Key, p => p.Value)) + { + ["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 093baf5c3..871425758 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,10 @@ private static SecretSchema GetSchema() return schema; } + + public bool Remove(string service, ICredential credential) => Remove(service, credential.Account); + + 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 c8956aaed..4cafc1035 100644 --- a/src/shared/Core/Interop/Linux/SecretServiceCredential.cs +++ b/src/shared/Core/Interop/Linux/SecretServiceCredential.cs @@ -1,8 +1,8 @@ +using System; using System.Diagnostics; namespace GitCredentialManager.Interop.Linux { - [DebuggerDisplay("{DebuggerDisplay}")] public class 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; set; } + + public DateTimeOffset? PasswordExpiry { get; set; } } } diff --git a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs index b024be129..6069ec984 100644 --- a/src/shared/Core/Interop/MacOS/MacOSKeychain.cs +++ b/src/shared/Core/Interop/MacOS/MacOSKeychain.cs @@ -14,6 +14,10 @@ public class MacOSKeychain : ICredentialStore { private readonly string _namespace; + public bool CanStorePasswordExpiry => false; + + public bool CanStoreOAuthRefreshToken => false; + #region Constructors /// @@ -348,5 +352,8 @@ private string CreateServiceName(string service) sb.Append(service); return sb.ToString(); } + + public void AddOrUpdate(string service, ICredential credential) => AddOrUpdate(service, credential.Account, credential.Password); + public bool Remove(string service, ICredential credential) => Remove(service, credential.Account); } } diff --git a/src/shared/Core/Interop/MacOS/MacOSKeychainCredential.cs b/src/shared/Core/Interop/MacOS/MacOSKeychainCredential.cs index 12e11e2b9..55c5329a2 100644 --- a/src/shared/Core/Interop/MacOS/MacOSKeychainCredential.cs +++ b/src/shared/Core/Interop/MacOS/MacOSKeychainCredential.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace GitCredentialManager.Interop.MacOS @@ -21,6 +22,10 @@ internal MacOSKeychainCredential(string service, string account, string password public string Password { get; } + public DateTimeOffset? PasswordExpiry => null; + + public string OAuthRefreshToken => null; + private string DebuggerDisplay => $"{Label} [Service: {Service}, Account: {Account}]"; } } diff --git a/src/shared/Core/Interop/Windows/WindowsCredential.cs b/src/shared/Core/Interop/Windows/WindowsCredential.cs index 6691c709b..2d01315ed 100644 --- a/src/shared/Core/Interop/Windows/WindowsCredential.cs +++ b/src/shared/Core/Interop/Windows/WindowsCredential.cs @@ -1,4 +1,6 @@ +using System; + namespace GitCredentialManager.Interop.Windows { public class WindowsCredential : ICredential @@ -19,6 +21,10 @@ public WindowsCredential(string service, string userName, string password, strin public string TargetName { get; } + public string OAuthRefreshToken { get; set; } + + public DateTimeOffset? PasswordExpiry => null; + string ICredential.Account => UserName; } } diff --git a/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs b/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs index f577ad301..75fb4119f 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,11 @@ 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 + + return new WindowsCredential(serviceName, credential.UserName, password, targetName) { + OAuthRefreshToken = oauth_refresh_token, + }; } public /* for testing */ static string RemoveUriUserInfo(string url) @@ -371,5 +394,11 @@ private WindowsCredential CreateCredentialFromStructure(Win32Credential credenti return sb.ToString(); } + + public bool Remove(string service, ICredential credential) => Remove(service, credential.Account); + + public bool CanStoreOAuthRefreshToken => true; + + public bool CanStorePasswordExpiry => false; } } diff --git a/src/shared/Core/NullCredentialStore.cs b/src/shared/Core/NullCredentialStore.cs index fac92f47c..350ee7432 100644 --- a/src/shared/Core/NullCredentialStore.cs +++ b/src/shared/Core/NullCredentialStore.cs @@ -9,6 +9,10 @@ namespace GitCredentialManager; /// public class NullCredentialStore : ICredentialStore { + public bool CanStorePasswordExpiry => false; + + public bool CanStoreOAuthRefreshToken => false; + public IList GetAccounts(string service) => Array.Empty(); public ICredential Get(string service, string account) => null; @@ -16,4 +20,6 @@ public class NullCredentialStore : ICredentialStore public void AddOrUpdate(string service, string account, string secret) { } public bool Remove(string service, string account) => false; + public void AddOrUpdate(string service, ICredential credential) { } + public bool Remove(string service, ICredential credential) => false; } diff --git a/src/shared/Core/PlaintextCredentialStore.cs b/src/shared/Core/PlaintextCredentialStore.cs index e88861c49..83a31032f 100644 --- a/src/shared/Core/PlaintextCredentialStore.cs +++ b/src/shared/Core/PlaintextCredentialStore.cs @@ -23,6 +23,10 @@ public PlaintextCredentialStore(IFileSystem fileSystem, string storeRoot, string protected string Namespace { get; } protected virtual string CredentialFileExtension => ".credential"; + public bool CanStorePasswordExpiry => false; + + public bool CanStoreOAuthRefreshToken => false; + public IList GetAccounts(string service) { return Enumerate(service, null).Select(x => x.Account).Distinct().ToList(); @@ -216,5 +220,8 @@ private string CreateServiceSlug(string service) return sb.ToString(); } + + public void AddOrUpdate(string service, ICredential credential) => AddOrUpdate(service, credential.Account, credential.Password); + public bool Remove(string service, ICredential credential) => Remove(service, credential.Account); } } diff --git a/src/shared/Core/StreamExtensions.cs b/src/shared/Core/StreamExtensions.cs index 7ff338f5a..d4012e1e6 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/GitHub/GitHubHostProvider.Commands.cs b/src/shared/GitHub/GitHubHostProvider.Commands.cs index ea3954752..6868debad 100644 --- a/src/shared/GitHub/GitHubHostProvider.Commands.cs +++ b/src/shared/GitHub/GitHubHostProvider.Commands.cs @@ -118,7 +118,7 @@ private async Task AddAccountAsync(Uri url, string userName, bool device, b credential = await GenerateCredentialAsync(url, userName); } - _context.CredentialStore.AddOrUpdate(service, credential.Account, credential.Password); + _context.CredentialStore.AddOrUpdate(service, credential); return 0; } diff --git a/src/shared/GitHub/GitHubHostProvider.cs b/src/shared/GitHub/GitHubHostProvider.cs index 21d29f651..8ba19a5e7 100644 --- a/src/shared/GitHub/GitHubHostProvider.cs +++ b/src/shared/GitHub/GitHubHostProvider.cs @@ -255,7 +255,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."); } @@ -351,7 +351,7 @@ private async Task GenerateOAuthCredentialAsync(Uri targetUri, st // Resolve the GitHub user handle GitHubUserInfo userInfo = await _gitHubApi.GetUserInfoAsync(targetUri, result.AccessToken); - return new GitCredential(userInfo.Login, result.AccessToken); + return new GitCredential(result, userInfo.Login); } private async Task GeneratePersonalAccessTokenAsync(Uri targetUri, ICredential credentials) @@ -514,6 +514,8 @@ internal static Uri NormalizeUri(Uri uri) return uri; } + public Task ValidateCredentialAsync(Uri remoteUri, ICredential credential) => Task.FromResult(credential.PasswordExpiry == null || credential.PasswordExpiry >= DateTimeOffset.UtcNow); + #endregion } } diff --git a/src/shared/GitLab.Tests/GitLabHostProviderTests.cs b/src/shared/GitLab.Tests/GitLabHostProviderTests.cs index c371ebf65..b424984e4 100644 --- a/src/shared/GitLab.Tests/GitLabHostProviderTests.cs +++ b/src/shared/GitLab.Tests/GitLabHostProviderTests.cs @@ -69,5 +69,11 @@ public void GitLabHostProvider_GetSupportedAuthenticationModes_Custom_WithOAuthC Assert.Equal(expected, actual); } + + [Fact] + public void GitLabHostProvider_GetRefreshTokenServiceName() + { + Assert.Equal("https://oauth-refresh-token.gitlab.example.com", GitLabHostProvider.GetRefreshTokenServiceName(new Uri("https://gitlab.example.com/abc/def.git"))); + } } } diff --git a/src/shared/GitLab/GitLabHostProvider.cs b/src/shared/GitLab/GitLabHostProvider.cs index 6cda3c0e1..ae336469b 100644 --- a/src/shared/GitLab/GitLabHostProvider.cs +++ b/src/shared/GitLab/GitLabHostProvider.cs @@ -106,6 +106,23 @@ public override async Task GenerateCredentialAsync(InputArguments i Uri remoteUri = input.GetRemoteUri(); + string refreshToken = input.OAuthRefreshToken; + if (!Context.CredentialStore.CanStoreOAuthRefreshToken) { + var refreshService = GetRefreshTokenServiceName(remoteUri); + refreshToken ??= Context.CredentialStore.Get(refreshService, "oauth2")?.Password; + } + + if (refreshToken != null) { + Context.Trace.WriteLine("Refreshing OAuth token..."); + try { + OAuth2TokenResult result = await _gitLabAuth.GetOAuthTokenViaRefresh(remoteUri, refreshToken); + return new GitCredential(result, "oauth2"); + } + catch (Exception e) { + Context.Trace.WriteLine($"Could not refresh OAuth token: {e.Message}"); + } + } + AuthenticationModes authModes = GetSupportedAuthenticationModes(remoteUri); AuthenticationPromptResult promptResult = await _gitLabAuth.GetAuthenticationAsync(remoteUri, input.UserName, authModes); @@ -178,50 +195,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) @@ -246,31 +227,10 @@ 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); - } - - 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() @@ -279,18 +239,28 @@ protected override void ReleaseManagedResources() base.ReleaseManagedResources(); } - private string GetRefreshTokenServiceName(InputArguments input) + internal static string GetRefreshTokenServiceName(Uri remoteUri) { - var builder = new UriBuilder(GetServiceName(input)); + var builder = new UriBuilder(remoteUri); builder.Host = "oauth-refresh-token." + builder.Host; - return builder.Uri.ToString(); + return builder.Uri.GetLeftPart(UriPartial.Authority).ToString(); } 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/Microsoft.AzureRepos/AzureReposHostProvider.cs b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs index 525704886..1539de123 100644 --- a/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs +++ b/src/shared/Microsoft.AzureRepos/AzureReposHostProvider.cs @@ -850,6 +850,8 @@ private Task UnbindCmd(string organization, bool local) return Task.FromResult(0); } + public Task ValidateCredentialAsync(Uri remoteUri, ICredential credential) => Task.FromResult(credential.PasswordExpiry == null || credential.PasswordExpiry >= DateTimeOffset.UtcNow); + #endregion } } diff --git a/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs b/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs index 6ef1e1866..eba928421 100644 --- a/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs +++ b/src/shared/TestInfrastructure/Objects/TestCredentialStore.cs @@ -1,3 +1,5 @@ +using Avalonia.OpenGL; +using System; using System.Collections.Generic; using System.Linq; @@ -44,10 +46,17 @@ bool ICredentialStore.Remove(string service, string account) return false; } + void ICredentialStore.AddOrUpdate(string service, ICredential credential) => Add(service, new TestCredential(service, credential)); + bool ICredentialStore.Remove(string service, ICredential credential) => (this as ICredentialStore).Remove(service, credential.Account); + #endregion public int Count => _store.Count; + public bool CanStorePasswordExpiry => true; + + public bool CanStoreOAuthRefreshToken => true; + public bool TryGet(string service, string account, out TestCredential credential) { credential = Query(service, account).FirstOrDefault(); @@ -102,10 +111,23 @@ public TestCredential(string service, string account, string password) Password = password; } + public TestCredential(string service, ICredential credential) + { + Service = service; + Account = credential.Account; + Password = credential.Password; + OAuthRefreshToken = credential.OAuthRefreshToken; + PasswordExpiry = credential.PasswordExpiry; + } + public string Service { get; } public string Account { get; } public string Password { get; } + + public DateTimeOffset? PasswordExpiry { get; set; } + + public string OAuthRefreshToken { get; set; } } } diff --git a/src/shared/TestInfrastructure/Objects/TestHostProvider.cs b/src/shared/TestInfrastructure/Objects/TestHostProvider.cs index a1a211bc6..6c10fed9b 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 } }