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..e3664ee66 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,9 +140,10 @@ 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) { @@ -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..03b945537 100644 --- a/src/shared/Core.Tests/Interop/Windows/WindowsCredentialManagerTests.cs +++ b/src/shared/Core.Tests/Interop/Windows/WindowsCredentialManagerTests.cs @@ -19,13 +19,15 @@ 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"; + DateTimeOffset testExpiry = DateTimeOffset.FromUnixTimeSeconds(1919539847); string expectedTargetName = $"{TestNamespace}:https://example.com/{uniqueGuid}"; try { // Write - credManager.AddOrUpdate(service, userName, password); + credManager.AddOrUpdate(service, new GitCredential(userName, password) { OAuthRefreshToken = testRefreshToken, PasswordExpiry = testExpiry }); // Read ICredential cred = credManager.Get(service, userName); @@ -37,6 +39,8 @@ public void WindowsCredentialManager_ReadWriteDelete() Assert.Equal(password, winCred.Password); Assert.Equal(service, winCred.Service); Assert.Equal(expectedTargetName, winCred.TargetName); + Assert.Equal(testRefreshToken, winCred.OAuthRefreshToken); + Assert.Equal(testExpiry, winCred.PasswordExpiry); } 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..9f961e31c 100644 --- a/src/shared/Core/Interop/Linux/SecretServiceCredential.cs +++ b/src/shared/Core/Interop/Linux/SecretServiceCredential.cs @@ -1,3 +1,4 @@ +using System; using System.Diagnostics; namespace GitCredentialManager.Interop.Linux @@ -18,6 +19,10 @@ internal SecretServiceCredential(string service, string account, string password public string Password { get; } + public string OAuthRefreshToken { get; set; } + + public DateTimeOffset? PasswordExpiry { get; set; } + private string DebuggerDisplay => $"[Service: {Service}, Account: {Account}]"; } } 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/Native/Advapi32.cs b/src/shared/Core/Interop/Windows/Native/Advapi32.cs index 14d4a3303..e746daf42 100644 --- a/src/shared/Core/Interop/Windows/Native/Advapi32.cs +++ b/src/shared/Core/Interop/Windows/Native/Advapi32.cs @@ -75,6 +75,7 @@ public enum CredentialEnumerateFlags AllCredentials = 0x1 } + /// [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct Win32Credential { @@ -106,4 +107,26 @@ public string GetCredentialBlobAsString() return null; } } + + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct Win32CredentialAttribute + { + [MarshalAs(UnmanagedType.LPWStr)] + public string Keyword; + public int Flags; + public int ValueSize; + public IntPtr Value; + + public long? GetValueAsLong() + { + if (ValueSize != 0 && Value != IntPtr.Zero) + { + byte[] bytes = InteropUtils.ToByteArray(Value, ValueSize); + return BitConverter.ToInt64(bytes, 0); + } + + return null; + } + } } diff --git a/src/shared/Core/Interop/Windows/WindowsCredential.cs b/src/shared/Core/Interop/Windows/WindowsCredential.cs index 6691c709b..d0cba2c58 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 { get; set; } + string ICredential.Account => UserName; } } diff --git a/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs b/src/shared/Core/Interop/Windows/WindowsCredentialManager.cs index f577ad301..63e0f460e 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; @@ -90,6 +98,25 @@ public void AddOrUpdate(string service, string account, string secret) UserName = account, }; + if (credential.PasswordExpiry != null) + { + byte[] expiryBytes = BitConverter.GetBytes(credential.PasswordExpiry.Value.ToUnixTimeSeconds()); + IntPtr expiryPtr = Marshal.AllocHGlobal(expiryBytes.Length); + Marshal.Copy(expiryBytes, 0, expiryPtr, expiryBytes.Length); + + var attribute = new Win32CredentialAttribute() + { + // consistent with https://github.com/git-for-windows/git/blob/main/contrib/credential/wincred/git-credential-wincred.c + Keyword = "git_password_expiry_utc", + Value = expiryPtr, + ValueSize = expiryBytes.Length, + }; + + newCred.AttributeCount = 1; + newCred.Attributes = Marshal.AllocHGlobal(Marshal.SizeOf(attribute)); + Marshal.StructureToPtr(attribute, newCred.Attributes, false); + } + int result = Win32Error.GetLastError( Advapi32.CredWrite(ref newCred, 0) ); @@ -211,7 +238,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 +263,23 @@ private WindowsCredential CreateCredentialFromStructure(Win32Credential credenti // Strip any userinfo component from the service name serviceName = RemoveUriUserInfo(serviceName); - return new WindowsCredential(serviceName, credential.UserName, password, targetName); + DateTimeOffset? passwordExpiry = null; + if (credential.Attributes != IntPtr.Zero) + { + for (int i = 0; i < credential.AttributeCount; i++) + { + Win32CredentialAttribute attr = Marshal.PtrToStructure(credential.Attributes + i * Marshal.SizeOf()); + if (attr.Keyword == "git_password_expiry_utc") + { + passwordExpiry = DateTimeOffset.FromUnixTimeSeconds(attr.GetValueAsLong().Value); + } + } + } + + return new WindowsCredential(serviceName, credential.UserName, password, targetName) { + OAuthRefreshToken = oauth_refresh_token, + PasswordExpiry = passwordExpiry, + }; } public /* for testing */ static string RemoveUriUserInfo(string url) @@ -371,5 +424,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 => true; } } 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..b05e93e2f 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 } }