Skip to content

Commit 33535c6

Browse files
authored
Merge pull request #104 from mjcheetham/bb-oauth
Add Bitbucket support (text prompts only)
2 parents 409ea02 + a61a59b commit 33535c6

15 files changed

+961
-2
lines changed

Git-Credential-Manager.sln

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{83BD5957-A
6060
EndProject
6161
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GitHub.UI.Windows", "src\windows\GitHub.UI.Windows\GitHub.UI.Windows.csproj", "{4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}"
6262
EndProject
63+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atlassian.Bitbucket", "src\shared\Atlassian.Bitbucket\Atlassian.Bitbucket.csproj", "{B49881A6-E734-490E-8EA7-FB0D9E296CFB}"
64+
EndProject
65+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Atlassian.Bitbucket.Tests", "src\shared\Atlassian.Bitbucket.Tests\Atlassian.Bitbucket.Tests.csproj", "{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}"
66+
EndProject
6367
Global
6468
GlobalSection(SolutionConfigurationPlatforms) = preSolution
6569
Debug|Any CPU = Debug|Any CPU
@@ -206,6 +210,30 @@ Global
206210
{4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
207211
{4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.WindowsRelease|Any CPU.ActiveCfg = Release|Any CPU
208212
{4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403}.WindowsRelease|Any CPU.Build.0 = Release|Any CPU
213+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
214+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.Debug|Any CPU.Build.0 = Debug|Any CPU
215+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.Release|Any CPU.ActiveCfg = Release|Any CPU
216+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.Release|Any CPU.Build.0 = Release|Any CPU
217+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU
218+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
219+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.WindowsRelease|Any CPU.ActiveCfg = Debug|Any CPU
220+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.WindowsRelease|Any CPU.Build.0 = Debug|Any CPU
221+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU
222+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.MacDebug|Any CPU.Build.0 = Debug|Any CPU
223+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.MacRelease|Any CPU.ActiveCfg = Debug|Any CPU
224+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB}.MacRelease|Any CPU.Build.0 = Debug|Any CPU
225+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
226+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
227+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
228+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.Release|Any CPU.Build.0 = Release|Any CPU
229+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.WindowsDebug|Any CPU.ActiveCfg = Debug|Any CPU
230+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.WindowsDebug|Any CPU.Build.0 = Debug|Any CPU
231+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.WindowsRelease|Any CPU.ActiveCfg = Debug|Any CPU
232+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.WindowsRelease|Any CPU.Build.0 = Debug|Any CPU
233+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacDebug|Any CPU.ActiveCfg = Debug|Any CPU
234+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacDebug|Any CPU.Build.0 = Debug|Any CPU
235+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacRelease|Any CPU.ActiveCfg = Debug|Any CPU
236+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0}.MacRelease|Any CPU.Build.0 = Debug|Any CPU
209237
EndGlobalSection
210238
GlobalSection(SolutionProperties) = preSolution
211239
HideSolutionNode = FALSE
@@ -227,6 +255,8 @@ Global
227255
{85903170-9E52-4B53-A6E4-3F416F684FAE} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9}
228256
{8DBBAB0A-970D-4BE3-958C-8CDC92F76549} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9}
229257
{4C2DBC8A-B3F2-4C64-870A-BA79DA4BD403} = {66722747-1B61-40E4-A89B-1AC8E6D62EA9}
258+
{B49881A6-E734-490E-8EA7-FB0D9E296CFB} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
259+
{025E5329-A0B1-4BA9-9203-B70B44A5F9E0} = {D5277A0E-997E-453A-8CB9-4EFCC8B16A29}
230260
EndGlobalSection
231261
GlobalSection(ExtensibilityGlobals) = postSolution
232262
SolutionGuid = {0EF9FC65-E6BA-45D4-A455-262A9EA4366B}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
22
<s:String x:Key="/Default/Environment/Hierarchy/Build/SolBuilderDuo/UseMsbuildSolutionBuilder/@EntryValue">No</s:String>
3-
<s:Boolean x:Key="/Default/UserDictionary/Words/=PKCE/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
3+
<s:Boolean x:Key="/Default/UserDictionary/Words/=PKCE/@EntryIndexedValue">True</s:Boolean>
4+
<s:Boolean x:Key="/Default/UserDictionary/Words/=Bitbucket/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netcoreapp3.1</TargetFramework>
5+
<IsPackable>false</IsPackable>
6+
<IsTestProject>true</IsTestProject>
7+
<LangVersion>latest</LangVersion>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
12+
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
13+
<DotNetCliToolReference Include="dotnet-xunit" Version="2.3.1" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\Atlassian.Bitbucket\Atlassian.Bitbucket.csproj" />
18+
<ProjectReference Include="..\TestInfrastructure\TestInfrastructure.csproj" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>netstandard2.0</TargetFrameworks>
5+
<TargetFrameworks Condition="'$(OSPlatform)'=='windows'">netstandard2.0;net461</TargetFrameworks>
6+
<AssemblyName>Atlassian.Bitbucket</AssemblyName>
7+
<RootNamespace>Atlassian.Bitbucket</RootNamespace>
8+
<IsTestProject>false</IsTestProject>
9+
<LangVersion>latest</LangVersion>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<ProjectReference Include="..\Microsoft.Git.CredentialManager\Microsoft.Git.CredentialManager.csproj" />
14+
</ItemGroup>
15+
16+
<ItemGroup Condition="'$(TargetFramework)' == 'net461'">
17+
<Reference Include="System.Net.Http" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
using System.Collections.Generic;
5+
using System.IO;
6+
using System.Net.Http;
7+
using System.Reflection;
8+
using System.Text;
9+
using System.Threading;
10+
using System.Threading.Tasks;
11+
using Microsoft.Git.CredentialManager;
12+
using Microsoft.Git.CredentialManager.Authentication;
13+
using Microsoft.Git.CredentialManager.Authentication.OAuth;
14+
15+
namespace Atlassian.Bitbucket
16+
{
17+
public interface IBitbucketAuthentication : IDisposable
18+
{
19+
Task<ICredential> GetBasicCredentialsAsync(Uri targetUri, string userName);
20+
Task<bool> ShowOAuthRequiredPromptAsync();
21+
Task<OAuth2TokenResult> CreateOAuthCredentialsAsync(Uri targetUri);
22+
Task<OAuth2TokenResult> RefreshOAuthCredentialsAsync(string refreshToken);
23+
}
24+
25+
public class BitbucketAuthentication : AuthenticationBase, IBitbucketAuthentication
26+
{
27+
public static readonly string[] AuthorityIds =
28+
{
29+
"bitbucket",
30+
};
31+
32+
private static readonly string[] Scopes =
33+
{
34+
BitbucketConstants.OAuthScopes.RepositoryWrite,
35+
BitbucketConstants.OAuthScopes.Account,
36+
};
37+
38+
public BitbucketAuthentication(ICommandContext context)
39+
: base(context) { }
40+
41+
#region IBitbucketAuthentication
42+
43+
public async Task<ICredential> GetBasicCredentialsAsync(Uri targetUri, string userName)
44+
{
45+
ThrowIfUserInteractionDisabled();
46+
47+
string password;
48+
49+
// Shell out to the UI helper and show the Bitbucket u/p prompt
50+
if (Context.IsDesktopSession && TryFindHelperExecutablePath(out string helperPath))
51+
{
52+
var cmdArgs = new StringBuilder("--prompt userpass");
53+
if (!string.IsNullOrWhiteSpace(userName))
54+
{
55+
cmdArgs.AppendFormat(" --username {0}", userName);
56+
}
57+
58+
IDictionary<string, string> output = await InvokeHelperAsync(helperPath, cmdArgs.ToString());
59+
60+
if (!output.TryGetValue("username", out userName))
61+
{
62+
throw new Exception("Missing username in response");
63+
}
64+
65+
if (!output.TryGetValue("password", out password))
66+
{
67+
throw new Exception("Missing password in response");
68+
}
69+
70+
return new GitCredential(userName, password);
71+
}
72+
else
73+
{
74+
ThrowIfTerminalPromptsDisabled();
75+
76+
Context.Terminal.WriteLine("Enter Bitbucket credentials for '{0}'...", targetUri);
77+
78+
if (!string.IsNullOrWhiteSpace(userName))
79+
{
80+
// Don't need to prompt for the username if it has been specified already
81+
Context.Terminal.WriteLine("Username: {0}", userName);
82+
}
83+
else
84+
{
85+
// Prompt for username
86+
userName = Context.Terminal.Prompt("Username");
87+
}
88+
89+
// Prompt for password
90+
password = Context.Terminal.PromptSecret("Password");
91+
92+
return new GitCredential(userName, password);
93+
}
94+
}
95+
96+
public async Task<bool> ShowOAuthRequiredPromptAsync()
97+
{
98+
ThrowIfUserInteractionDisabled();
99+
100+
// Shell out to the UI helper and show the Bitbucket prompt
101+
if (Context.IsDesktopSession && TryFindHelperExecutablePath(out string helperPath))
102+
{
103+
IDictionary<string, string> output = await InvokeHelperAsync(helperPath, "--prompt oauth");
104+
105+
if (output.TryGetValue("continue", out string continueStr) && continueStr.IsTruthy())
106+
{
107+
return true;
108+
}
109+
110+
return false;
111+
}
112+
else
113+
{
114+
ThrowIfTerminalPromptsDisabled();
115+
116+
Context.Terminal.WriteLine($"Your account has two-factor authentication enabled.{Environment.NewLine}" +
117+
$"To continue you must complete authentication in your web browser.{Environment.NewLine}");
118+
119+
var _ = Context.Terminal.Prompt("Press enter to continue...");
120+
return true;
121+
}
122+
}
123+
124+
public async Task<OAuth2TokenResult> CreateOAuthCredentialsAsync(Uri targetUri)
125+
{
126+
var oauthClient = new BitbucketOAuth2Client(HttpClient, Context.Settings);
127+
128+
var browserOptions = new OAuth2WebBrowserOptions
129+
{
130+
SuccessResponseHtml = BitbucketResources.AuthenticationResponseSuccessHtml,
131+
FailureResponseHtmlFormat = BitbucketResources.AuthenticationResponseFailureHtmlFormat
132+
};
133+
134+
var browser = new OAuth2SystemWebBrowser(browserOptions);
135+
var authCodeResult = await oauthClient.GetAuthorizationCodeAsync(Scopes, browser, CancellationToken.None);
136+
137+
return await oauthClient.GetTokenByAuthorizationCodeAsync(authCodeResult, CancellationToken.None);
138+
}
139+
140+
public async Task<OAuth2TokenResult> RefreshOAuthCredentialsAsync(string refreshToken)
141+
{
142+
var oauthClient = new BitbucketOAuth2Client(HttpClient, Context.Settings);
143+
144+
return await oauthClient.GetTokenByRefreshTokenAsync(refreshToken, CancellationToken.None);
145+
}
146+
147+
#endregion
148+
149+
#region Private Methods
150+
151+
private bool TryFindHelperExecutablePath(out string path)
152+
{
153+
string helperName = BitbucketConstants.BitbucketAuthHelperName;
154+
155+
if (PlatformUtils.IsWindows())
156+
{
157+
helperName += ".exe";
158+
}
159+
160+
string executableDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
161+
path = Path.Combine(executableDirectory, helperName);
162+
return Context.FileSystem.FileExists(path);
163+
}
164+
165+
private HttpClient _httpClient;
166+
private HttpClient HttpClient => _httpClient ??= Context.HttpClientFactory.CreateClient();
167+
168+
#endregion
169+
170+
#region IDisposable
171+
172+
public void Dispose()
173+
{
174+
_httpClient?.Dispose();
175+
}
176+
177+
#endregion
178+
}
179+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
5+
namespace Atlassian.Bitbucket
6+
{
7+
public static class BitbucketConstants
8+
{
9+
public const string BitbucketBaseUrlHost = "bitbucket.org";
10+
public static readonly Uri BitbucketApiUri = new Uri("https://api.bitbucket.org");
11+
public const string BitbucketAuthHelperName = "Atlassian.Bitbucket.UI";
12+
13+
// TODO: use the GCM client ID and secret once we have this approved.
14+
// Until then continue to use Sourcetree's values like GCM Windows.
15+
//public const string OAuth2ClientId = "b5AKdPfpgFdEGpKzPE";
16+
//public const string OAuth2ClientSecret = "7NUP5qUtSR3SxdFK4xAGaU6PMNvNdE59";
17+
//public static readonly Uri OAuth2RedirectUri = new Uri("http://localhost:46337/");
18+
public const string OAuth2ClientId = "HJdmKXV87DsmC9zSWB";
19+
public const string OAuth2ClientSecret = "wwWw47VB9ZHwMsD4Q4rAveHkbxNrMp3n";
20+
public static readonly Uri OAuth2RedirectUri = new Uri("http://localhost:34106/");
21+
22+
public static readonly Uri OAuth2AuthorizationEndpoint = new Uri("https://bitbucket.org/site/oauth2/authorize");
23+
public static readonly Uri OAuth2TokenEndpoint = new Uri("https://bitbucket.org/site/oauth2/access_token");
24+
25+
public static class OAuthScopes
26+
{
27+
public const string RepositoryWrite = "repository:write";
28+
public const string Account = "account";
29+
}
30+
31+
public static class EnvironmentVariables
32+
{
33+
public const string DevOAuthClientId = "GCM_DEV_BITBUCKET_CLIENTID";
34+
public const string DevOAuthClientSecret = "GCM_DEV_BITBUCKET_CLIENTSECRET";
35+
public const string DevOAuthRedirectUri = "GCM_DEV_BITBUCKET_REDIRECTURI";
36+
}
37+
38+
public static class GitConfiguration
39+
{
40+
public static class Credential
41+
{
42+
public const string DevOAuthClientId = "bitbucketDevClientId";
43+
public const string DevOAuthClientSecret = "bitbucketDevClientSecret";
44+
public const string DevOAuthRedirectUri = "bitbucketDevRedirectUri";
45+
}
46+
}
47+
}
48+
}

0 commit comments

Comments
 (0)