Skip to content

Commit 855c433

Browse files
committed
Issue 573 Add support for OAuth2 for Bitbucket DC
New implementations of the interface IBitbucketRestApi and abstract class BitbucketOAuth2Client provide client code for interacting with the Bitbucket DC REST API and OAuth2 implementation. Extending the BitbucketRestApiRegistry and OAuth2ClientRegistry to provide these Bitbucket DC implementations when a DC host is detected means BitbucketAuthentication and BitbucketHostProvider remain agnostic about which form of Bitbcuket host they are interacting with Prior to this feature users were limited to using Basic Auth or HTTP Access Tokens to interact securely with a Bitbucket DC instance. OAuth2 provides a better solution for SSO environments
1 parent e705869 commit 855c433

18 files changed

+731
-20
lines changed

src/shared/Atlassian.Bitbucket.Tests/BitbucketHostProviderTest.cs

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Atlassian.Bitbucket.Cloud;
2+
using Atlassian.Bitbucket.DataCenter;
23
using GitCredentialManager;
34
using GitCredentialManager.Authentication.OAuth;
45
using GitCredentialManager.Tests.Objects;
@@ -297,7 +298,6 @@ public async Task BitbucketHostProvider_GetCredentialAsync_PreconfiguredMode_OAu
297298
}
298299

299300
[Theory]
300-
// DC/Server does not currently support OAuth
301301
[InlineData("https", BITBUCKET_DOT_ORG_HOST, "jsquire", MOCK_ACCESS_TOKEN, MOCK_ACCESS_TOKEN_ALT, MOCK_REFRESH_TOKEN)]
302302
public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredentials_OAuth_IsRespected(
303303
string protocol, string host, string username, string storedToken, string newToken, string refreshToken)
@@ -357,6 +357,9 @@ public async Task BitbucketHostProvider_GetCredentialAsync_AlwaysRefreshCredenti
357357
// DC - supports Basic, OAuth
358358
[InlineData("https", "example.com", "basic", AuthenticationModes.Basic)]
359359
[InlineData("https", "example.com", "oauth", AuthenticationModes.OAuth)]
360+
[InlineData("https", "example.com", "NOT-A-REAL-VALUE", DataCenterConstants.ServerAuthenticationModes)]
361+
[InlineData("https", "example.com", "none", DataCenterConstants.ServerAuthenticationModes)]
362+
[InlineData("https", "example.com", null, DataCenterConstants.ServerAuthenticationModes)]
360363
// Cloud - supports Basic, OAuth
361364
[InlineData("https", "bitbucket.org", "oauth", AuthenticationModes.OAuth)]
362365
[InlineData("https", "bitbucket.org", "basic", AuthenticationModes.Basic)]
@@ -496,15 +499,13 @@ private void MockPromptOAuth(InputArguments input)
496499

497500
private void MockRemoteBasicValid(InputArguments input, string password, bool twoFactor = true)
498501
{
499-
var userInfo = new UserInfo
500-
{
501-
UserName = input.UserName,
502-
IsTwoFactorAuthenticationEnabled = twoFactor
503-
};
502+
var userInfo = new Mock<IUserInfo>(MockBehavior.Strict);
503+
userInfo.Setup(ui => ui.IsTwoFactorAuthenticationEnabled).Returns(twoFactor);
504+
userInfo.Setup(ui => ui.UserName).Returns(input.UserName);
504505

505506
// Basic
506507
bitbucketApi.Setup(x => x.GetUserInformationAsync(input.UserName, password, false))
507-
.ReturnsAsync(new RestApiResult<IUserInfo>(System.Net.HttpStatusCode.OK, userInfo));
508+
.ReturnsAsync(new RestApiResult<IUserInfo>(System.Net.HttpStatusCode.OK, userInfo.Object));
508509
}
509510

510511
private void MockRemoteAccessTokenExpired(InputArguments input, string token)
@@ -516,15 +517,13 @@ private void MockRemoteAccessTokenExpired(InputArguments input, string token)
516517

517518
private void MockRemoteAccessTokenValid(InputArguments input, string token, bool twoFactor = true)
518519
{
519-
var userInfo = new UserInfo
520-
{
521-
UserName = input.UserName,
522-
IsTwoFactorAuthenticationEnabled = twoFactor
523-
};
520+
var userInfo = new Mock<IUserInfo>(MockBehavior.Strict);
521+
userInfo.Setup(ui => ui.IsTwoFactorAuthenticationEnabled).Returns(twoFactor);
522+
userInfo.Setup(ui => ui.UserName).Returns(input.UserName);
524523

525524
// OAuth
526525
bitbucketApi.Setup(x => x.GetUserInformationAsync(null, token, true))
527-
.ReturnsAsync(new RestApiResult<IUserInfo>(System.Net.HttpStatusCode.OK, userInfo));
526+
.ReturnsAsync(new RestApiResult<IUserInfo>(System.Net.HttpStatusCode.OK, userInfo.Object));
528527
}
529528

530529
private static void MockRemoteOAuthAccountIsInvalid(Mock<IBitbucketRestApi> bitbucketApi)

src/shared/Atlassian.Bitbucket.Tests/BitbucketRestApiRegistryTest.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,27 @@ public void BitbucketRestApiRegistry_Get_ReturnsCloudApi_ForBitbucketOrg()
3232
Assert.IsType<Atlassian.Bitbucket.Cloud.BitbucketRestApi>(api);
3333

3434
}
35+
36+
[Fact]
37+
public void BitbucketRestApiRegistry_Get_ReturnsDataCenterApi_ForBitbucketDC()
38+
{
39+
// Given
40+
settings.Setup(s => s.RemoteUri).Returns(new System.Uri("https://example.com"));
41+
context.Setup(c => c.Settings).Returns(settings.Object);
42+
43+
var input = new InputArguments(new Dictionary<string, string>
44+
{
45+
["protocol"] = "http",
46+
["host"] = "example.com",
47+
});
48+
49+
// When
50+
var registry = new BitbucketRestApiRegistry(context.Object);
51+
var api = registry.Get(input);
52+
53+
// Then
54+
Assert.NotNull(api);
55+
Assert.IsType<Atlassian.Bitbucket.DataCenter.BitbucketRestApi>(api);
56+
}
3557
}
3658
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using Atlassian.Bitbucket.DataCenter;
8+
using GitCredentialManager;
9+
using GitCredentialManager.Authentication.OAuth;
10+
using Moq;
11+
using Xunit;
12+
13+
namespace Atlassian.Bitbucket.Tests.DataCenter
14+
{
15+
public class BitbucketOAuth2ClientTest
16+
{
17+
private Mock<HttpClient> httpClient = new Mock<HttpClient>(MockBehavior.Strict);
18+
private Mock<ISettings> settings = new Mock<ISettings>(MockBehavior.Loose);
19+
private Mock<Trace> trace = new Mock<Trace>(MockBehavior.Loose);
20+
private Mock<IOAuth2WebBrowser> browser = new Mock<IOAuth2WebBrowser>(MockBehavior.Strict);
21+
private Mock<IOAuth2CodeGenerator> codeGenerator = new Mock<IOAuth2CodeGenerator>(MockBehavior.Strict);
22+
private CancellationToken ct = new CancellationToken();
23+
private Uri rootCallbackUri = new Uri("http://localhost:34106/");
24+
private string nonce = "12345";
25+
private string pkceCodeVerifier = "abcde";
26+
private string pkceCodeChallenge = "xyz987";
27+
private string authorization_code = "authorization_token";
28+
29+
[Fact]
30+
public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode()
31+
{
32+
var remoteUrl = MockRemoteUri("http://example.com");
33+
var clientId = MockClientIdOverride("dc-client-id");
34+
MockClientSecretOverride("dc-client-seccret");
35+
36+
Uri finalCallbackUri = MockFinalCallbackUri(rootCallbackUri);
37+
38+
var client = GetBitbucketOAuth2Client();
39+
40+
MockGetAuthenticationCodeAsync(remoteUrl, rootCallbackUri, finalCallbackUri, clientId, client.Scopes);
41+
42+
MockCodeGenerator();
43+
44+
var result = await client.GetAuthorizationCodeAsync(browser.Object, ct);
45+
46+
VerifyAuthorizationCodeResult(result, rootCallbackUri);
47+
}
48+
49+
[Fact]
50+
public async Task BitbucketOAuth2Client_GetAuthorizationCodeAsync_ReturnsCode_WhileRespectingRedirectUriOverride()
51+
{
52+
var rootCallbackUrl = MockRootCallbackUriOverride("http://localhost:12345/");
53+
var remoteUrl = MockRemoteUri("http://example.com");
54+
var clientId = MockClientIdOverride("dc-client-id");
55+
MockClientSecretOverride("dc-client-seccret");
56+
57+
Uri finalCallbackUri = MockFinalCallbackUri(new Uri(rootCallbackUrl));
58+
59+
var client = GetBitbucketOAuth2Client();
60+
61+
MockGetAuthenticationCodeAsync(remoteUrl, new Uri(rootCallbackUrl), finalCallbackUri, clientId, client.Scopes);
62+
63+
MockCodeGenerator();
64+
65+
var result = await client.GetAuthorizationCodeAsync(browser.Object, ct);
66+
67+
VerifyAuthorizationCodeResult(result, new Uri(rootCallbackUrl));
68+
}
69+
70+
private void VerifyAuthorizationCodeResult(OAuth2AuthorizationCodeResult result, Uri redirectUri)
71+
{
72+
Assert.NotNull(result);
73+
Assert.Equal(authorization_code, result.Code);
74+
Assert.Equal(redirectUri, result.RedirectUri);
75+
Assert.Equal(pkceCodeVerifier, result.CodeVerifier);
76+
}
77+
78+
private Bitbucket.DataCenter.BitbucketOAuth2Client GetBitbucketOAuth2Client()
79+
{
80+
var client = new Bitbucket.DataCenter.BitbucketOAuth2Client(httpClient.Object, settings.Object, trace.Object);
81+
client.CodeGenerator = codeGenerator.Object;
82+
return client;
83+
}
84+
85+
private void MockCodeGenerator()
86+
{
87+
codeGenerator.Setup(c => c.CreateNonce()).Returns(nonce);
88+
codeGenerator.Setup(c => c.CreatePkceCodeVerifier()).Returns(pkceCodeVerifier);
89+
codeGenerator.Setup(c => c.CreatePkceCodeChallenge(OAuth2PkceChallengeMethod.Sha256, pkceCodeVerifier)).Returns(pkceCodeChallenge);
90+
}
91+
92+
private void MockGetAuthenticationCodeAsync(string url, Uri redirectUri, Uri finalCallbackUri, string overrideClientId, IEnumerable<string> scopes)
93+
{
94+
var authorizationUri = new UriBuilder(url + "/rest/oauth2/latest/authorize")
95+
{
96+
Query = "?response_type=code"
97+
+ "&client_id=" + (overrideClientId ?? "clientId")
98+
+ "&state=12345"
99+
+ "&code_challenge_method=" + OAuth2Constants.AuthorizationEndpoint.PkceChallengeMethodS256
100+
+ "&code_challenge=" + WebUtility.UrlEncode(pkceCodeChallenge).ToLower()
101+
+ "&redirect_uri=" + WebUtility.UrlEncode(redirectUri.AbsoluteUri).ToLower()
102+
+ "&scope=" + WebUtility.UrlEncode(string.Join(" ", scopes)).ToUpper()
103+
}.Uri;
104+
105+
browser.Setup(b => b.GetAuthenticationCodeAsync(authorizationUri, redirectUri, ct)).Returns(Task.FromResult(finalCallbackUri));
106+
}
107+
108+
private Uri MockFinalCallbackUri(Uri redirectUri)
109+
{
110+
var finalUri = new Uri(rootCallbackUri, "?state=" + nonce + "&code=" + authorization_code);
111+
// This is a simplification but consistent
112+
browser.Setup(b => b.UpdateRedirectUri(redirectUri)).Returns(redirectUri);
113+
return finalUri;
114+
}
115+
116+
private string MockRemoteUri(string value)
117+
{
118+
settings.Setup(s => s.RemoteUri).Returns(new Uri(value));
119+
return value;
120+
}
121+
122+
private string MockClientIdOverride(string value)
123+
{
124+
settings.Setup(s => s.TryGetSetting(
125+
DataCenterConstants.EnvironmentVariables.OAuthClientId,
126+
Constants.GitConfiguration.Credential.SectionName, DataCenterConstants.GitConfiguration.Credential.OAuthClientId,
127+
out value)).Returns(true);
128+
return value;
129+
}
130+
131+
private string MockClientSecretOverride(string value)
132+
{
133+
settings.Setup(s => s.TryGetSetting(
134+
DataCenterConstants.EnvironmentVariables.OAuthClientSecret,
135+
Constants.GitConfiguration.Credential.SectionName, DataCenterConstants.GitConfiguration.Credential.OAuthClientSecret,
136+
out value)).Returns(true);
137+
return value;
138+
}
139+
140+
private string MockRootCallbackUriOverride(string value)
141+
{
142+
settings.Setup(s => s.TryGetSetting(
143+
DataCenterConstants.EnvironmentVariables.OAuthRedirectUri,
144+
Constants.GitConfiguration.Credential.SectionName, DataCenterConstants.GitConfiguration.Credential.OAuthRedirectUri,
145+
out value)).Returns(true);
146+
return value;
147+
}
148+
}
149+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Net;
4+
using System.Net.Http;
5+
using System.Threading.Tasks;
6+
using Atlassian.Bitbucket.DataCenter;
7+
using GitCredentialManager.Tests.Objects;
8+
using Xunit;
9+
10+
namespace Atlassian.Bitbucket.Tests.DataCenter
11+
{
12+
public class BitbucketRestApiTest
13+
{
14+
[Fact]
15+
public async Task BitbucketRestApi_GetUserInformationAsync_ReturnsUserInfo_ForSuccessfulRequest_DoesNothing()
16+
{
17+
var twoFactorAuthenticationEnabled = false;
18+
19+
var context = new TestCommandContext();
20+
21+
var expectedRequestUri = new Uri("http://example.com/rest/api/1.0/users");
22+
var httpHandler = new TestHttpMessageHandler();
23+
var httpResponse = new HttpResponseMessage(HttpStatusCode.OK);
24+
httpHandler.Setup(HttpMethod.Get, expectedRequestUri, request =>
25+
{
26+
return httpResponse;
27+
});
28+
context.HttpClientFactory.MessageHandler = httpHandler;
29+
30+
context.Settings.RemoteUri = new Uri("http://example.com");
31+
32+
var api = new BitbucketRestApi(context);
33+
var result = await api.GetUserInformationAsync("never used", "never used", false);
34+
35+
Assert.NotNull(result);
36+
Assert.Equal(DataCenterConstants.OAuthUserName, result.Response.UserName);
37+
Assert.Equal(twoFactorAuthenticationEnabled, result.Response.IsTwoFactorAuthenticationEnabled);
38+
39+
httpHandler.AssertRequest(HttpMethod.Get, expectedRequestUri, 1);
40+
}
41+
42+
[Theory]
43+
[InlineData(HttpStatusCode.Unauthorized, true)]
44+
[InlineData(HttpStatusCode.NotFound, false)]
45+
public async Task BitbucketRestApi_IsOAuthInstalledAsync_ReflectsBitbucketAuthenticationResponse(HttpStatusCode responseCode, bool impliedSupport)
46+
{
47+
var context = new TestCommandContext();
48+
var httpHandler = new TestHttpMessageHandler();
49+
50+
var expectedRequestUri = new Uri("http://example.com/rest/oauth2/1.0/client");
51+
52+
var httpResponse = new HttpResponseMessage(responseCode);
53+
httpHandler.Setup(HttpMethod.Get, expectedRequestUri, request =>
54+
{
55+
return httpResponse;
56+
});
57+
58+
context.HttpClientFactory.MessageHandler = httpHandler;
59+
context.Settings.RemoteUri = new Uri("http://example.com");
60+
61+
var api = new BitbucketRestApi(context);
62+
63+
var isInstalled = await api.IsOAuthInstalledAsync();
64+
65+
httpHandler.AssertRequest(HttpMethod.Get, expectedRequestUri, 1);
66+
67+
Assert.Equal(impliedSupport, isInstalled);
68+
}
69+
70+
[Theory]
71+
[MemberData(nameof(GetAuthenticationMethodsAsyncData))]
72+
public async Task BitbucketRestApi_GetAuthenticationMethodsAsync_ReflectRestApiResponse(string loginOptionResponseJson, List<AuthenticationMethod> impliedSupportedMethods, List<AuthenticationMethod> impliedUnsupportedMethods)
73+
{
74+
var context = new TestCommandContext();
75+
var httpHandler = new TestHttpMessageHandler();
76+
77+
var expectedRequestUri = new Uri("http://example.com/rest/authconfig/1.0/login-options");
78+
79+
var httpResponse = new HttpResponseMessage(HttpStatusCode.OK)
80+
{
81+
Content = new StringContent(loginOptionResponseJson)
82+
};
83+
84+
httpHandler.Setup(HttpMethod.Get, expectedRequestUri, request =>
85+
{
86+
return httpResponse;
87+
});
88+
89+
context.HttpClientFactory.MessageHandler = httpHandler;
90+
context.Settings.RemoteUri = new Uri("http://example.com");
91+
92+
var api = new BitbucketRestApi(context);
93+
94+
var authMethods = await api.GetAuthenticationMethodsAsync();
95+
96+
httpHandler.AssertRequest(HttpMethod.Get, expectedRequestUri, 1);
97+
98+
Assert.NotNull(authMethods);
99+
Assert.Equal(authMethods.Count, impliedSupportedMethods.Count);
100+
Assert.Contains(authMethods, m => impliedSupportedMethods.Contains(m));
101+
Assert.DoesNotContain(authMethods, m => impliedUnsupportedMethods.Contains(m));
102+
}
103+
104+
public static IEnumerable<object[]> GetAuthenticationMethodsAsyncData =>
105+
new List<object[]>
106+
{
107+
new object[] { $"{{ \"results\":[ {{ \"type\":\"LOGIN_FORM\"}}]}}",
108+
new List<AuthenticationMethod>{AuthenticationMethod.BasicAuth},
109+
new List<AuthenticationMethod>{AuthenticationMethod.Sso}},
110+
new object[] { $"{{ \"results\":[{{\"type\":\"IDP\"}}]}}",
111+
new List<AuthenticationMethod>{AuthenticationMethod.Sso},
112+
new List<AuthenticationMethod>{AuthenticationMethod.BasicAuth}},
113+
new object[] { $"{{ \"results\":[{{\"type\":\"IDP\"}}, {{ \"type\":\"LOGIN_FORM\"}}, {{ \"type\":\"UNDEFINED\"}}]}}",
114+
new List<AuthenticationMethod>{AuthenticationMethod.Sso, AuthenticationMethod.BasicAuth},
115+
new List<AuthenticationMethod>()},
116+
};
117+
}
118+
}

0 commit comments

Comments
 (0)