Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store PasswordExpiry and OAuthRefreshToken #1464

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 9 additions & 19 deletions src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -316,15 +313,14 @@ 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(
BitbucketConstants.EnvironmentVariables.AlwaysRefreshCredentials, bool.TrueString);

// 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);

Expand Down Expand Up @@ -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<string, string>
{
["protocol"] = protocol,
["host"] = host,
["username"] = username
["username"] = username,
["oauth_refresh_token"] = refreshToken,
});
}

Expand Down Expand Up @@ -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))
Expand Down
44 changes: 26 additions & 18 deletions src/shared/Atlassian.Bitbucket/BitbucketHostProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace Atlassian.Bitbucket
{
// TODO: simplify and inherit from HostProvider
public class BitbucketHostProvider : IHostProvider
{
private readonly ICommandContext _context;
Expand Down Expand Up @@ -139,9 +140,10 @@ private async Task<ICredential> 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)
{
Expand Down Expand Up @@ -199,26 +201,28 @@ private async Task<ICredential> GetRefreshedCredentials(InputArguments input, Au
return await GetOAuthCredentialsViaInteractiveBrowserFlow(input);
}

private async Task<ICredential> GetOAuthCredentialsViaRefreshFlow(InputArguments input, ICredential refreshToken)
private async Task<ICredential> 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<ICredential> GetOAuthCredentialsViaInteractiveBrowserFlow(InputArguments input)
Expand All @@ -239,13 +243,15 @@ private async Task<ICredential> 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)
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -450,7 +456,7 @@ private async Task<bool> ValidateCredentialsWork(InputArguments input, ICredenti
return true;
}

private static string GetServiceName(Uri remoteUri)
internal static string GetServiceName(Uri remoteUri)
{
return remoteUri.WithoutUserInfo().AbsoluteUri.TrimEnd('/');
}
Expand All @@ -473,5 +479,7 @@ public void Dispose()
_restApiRegistry.Dispose();
_bitbucketAuth.Dispose();
}

public Task<bool> ValidateCredentialAsync(Uri remoteUri, ICredential credential) => Task.FromResult(credential.PasswordExpiry == null || credential.PasswordExpiry >= DateTimeOffset.UtcNow);
}
}
12 changes: 10 additions & 2 deletions src/shared/Core.Tests/Commands/GetCommandTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
Expand All @@ -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<string, string>
{
["protocol"] = "http",
["host"] = "example.com",
["username"] = testUserName,
["password"] = testPassword
["password"] = testPassword,
["password_expiry_utc"] = testExpiry.ToString(),
["oauth_refresh_token"] = testRefreshToken,
};

var providerMock = new Mock<IHostProvider>();
Expand Down
12 changes: 9 additions & 3 deletions src/shared/Core.Tests/Commands/StoreCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
{
["protocol"] = "http",
["host"] = "example.com",
["username"] = testUserName,
["password"] = testPassword
["password"] = testPassword,
["oauth_refresh_token"] = testRefreshToken,
["password_expiry_utc"] = testExpiry.ToString(),
});

var providerMock = new Mock<IHostProvider>();
Expand All @@ -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;
}
}
}
8 changes: 2 additions & 6 deletions src/shared/Core.Tests/GenericHostProviderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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);
Expand All @@ -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<OAuth2Client>(), scopes), Times.Once);
Expand Down
Loading
Loading