Skip to content

Commit 882b9f0

Browse files
authored
Merge pull request #10 from mjcheetham/azure-repos-provider
Add Azure Repos provider (not including MSA/AAD authentication helpers)
2 parents 72a1c6d + e20f236 commit 882b9f0

21 files changed

+1707
-97
lines changed

Git-Credential-Manager.sln

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Git.CredentialMan
1010
EndProject
1111
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Git.CredentialManager.Tests", "tests\Microsoft.Git.CredentialManager.Tests\Microsoft.Git.CredentialManager.Tests.csproj", "{AD41FA1E-51F5-4E4F-B7DA-32F921491313}"
1212
EndProject
13+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AzureRepos", "src\Microsoft.AzureRepos\Microsoft.AzureRepos.csproj", "{714AF9EB-44E6-4058-BD3E-9039F29F4D7A}"
14+
EndProject
15+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AzureRepos.Tests", "tests\Microsoft.AzureRepos.Tests\Microsoft.AzureRepos.Tests.csproj", "{97DC6241-1240-4A85-8035-F8404A983A82}"
16+
EndProject
1317
Global
1418
GlobalSection(SolutionConfigurationPlatforms) = preSolution
1519
Debug|Any CPU = Debug|Any CPU
@@ -19,6 +23,8 @@ Global
1923
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
2024
{31BCFC70-B767-4274-873F-1A076D422FC3} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
2125
{AD41FA1E-51F5-4E4F-B7DA-32F921491313} = {4B305AC9-153F-4EA3-822F-3E5023BABAF1}
26+
{714AF9EB-44E6-4058-BD3E-9039F29F4D7A} = {A7FC1234-95E3-4496-B5F7-4306F41E6A0E}
27+
{97DC6241-1240-4A85-8035-F8404A983A82} = {4B305AC9-153F-4EA3-822F-3E5023BABAF1}
2228
EndGlobalSection
2329
GlobalSection(ProjectConfigurationPlatforms) = postSolution
2430
{28F06D44-AB25-4CF5-93F9-978C23FAA9D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -33,5 +39,13 @@ Global
3339
{AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Debug|Any CPU.Build.0 = Debug|Any CPU
3440
{AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Release|Any CPU.ActiveCfg = Release|Any CPU
3541
{AD41FA1E-51F5-4E4F-B7DA-32F921491313}.Release|Any CPU.Build.0 = Release|Any CPU
42+
{714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
43+
{714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
44+
{714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
45+
{714AF9EB-44E6-4058-BD3E-9039F29F4D7A}.Release|Any CPU.Build.0 = Release|Any CPU
46+
{97DC6241-1240-4A85-8035-F8404A983A82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
47+
{97DC6241-1240-4A85-8035-F8404A983A82}.Debug|Any CPU.Build.0 = Debug|Any CPU
48+
{97DC6241-1240-4A85-8035-F8404A983A82}.Release|Any CPU.ActiveCfg = Release|Any CPU
49+
{97DC6241-1240-4A85-8035-F8404A983A82}.Release|Any CPU.Build.0 = Release|Any CPU
3650
EndGlobalSection
3751
EndGlobal
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license.
3+
using System;
4+
5+
namespace Microsoft.AzureRepos
6+
{
7+
internal static class AzureDevOpsConstants
8+
{
9+
// Azure DevOps's resource ID
10+
public const string AadResourceId = "499b84ac-1321-427f-aa17-267ca6975798";
11+
12+
// Visual Studio's client ID
13+
// We share this to be able to consume existing access tokens from the VS caches
14+
public const string AadClientId = "872cd9fa-d31f-45e0-9eab-6e460a02d1f1";
15+
16+
// Standard redirect URI for native client 'v1 protocol' applications
17+
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v1-protocols-oauth-code#request-an-authorization-code
18+
public static readonly Uri AadRedirectUri = new Uri("urn:ietf:wg:oauth:2.0:oob");
19+
20+
public const string VstsHostSuffix = ".visualstudio.com";
21+
public const string AzureDevOpsHost = "dev.azure.com";
22+
23+
public const string VssResourceTenantHeader = "X-VSS-ResourceTenant";
24+
25+
public static class PersonalAccessTokenScopes
26+
{
27+
public const string ReposWrite = "vso.code_write";
28+
public const string ArtifactsRead = "vso.packaging";
29+
}
30+
}
31+
}
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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.Linq;
6+
using System.Net.Http;
7+
using System.Net.Http.Headers;
8+
using System.Text;
9+
using System.Text.RegularExpressions;
10+
using System.Threading.Tasks;
11+
using Microsoft.Git.CredentialManager;
12+
13+
namespace Microsoft.AzureRepos
14+
{
15+
public interface IAzureDevOpsRestApi
16+
{
17+
Task<string> GetAuthorityAsync(Uri organizationUri);
18+
Task<string> CreatePersonalAccessTokenAsync(Uri organizationUri, string accessToken, IEnumerable<string> scopes);
19+
}
20+
21+
public class AzureDevOpsRestApi : IAzureDevOpsRestApi
22+
{
23+
24+
private readonly ICommandContext _context;
25+
private readonly IHttpClientFactory _httpFactory;
26+
27+
public AzureDevOpsRestApi(ICommandContext context)
28+
: this(context, new HttpClientFactory()) { }
29+
30+
public AzureDevOpsRestApi(ICommandContext context, IHttpClientFactory httpFactory)
31+
{
32+
EnsureArgument.NotNull(context, nameof(context));
33+
EnsureArgument.NotNull(httpFactory, nameof(httpFactory));
34+
35+
_context = context;
36+
_httpFactory = httpFactory;
37+
}
38+
39+
public async Task<string> GetAuthorityAsync(Uri organizationUri)
40+
{
41+
EnsureArgument.AbsoluteUri(organizationUri, nameof(organizationUri));
42+
43+
const string authorityBase = "https://login.microsoftonline.com/";
44+
const string commonAuthority = authorityBase + "common";
45+
const string msaAuthority = authorityBase + "live.com";
46+
47+
var headers = new[] {Constants.Http.AcceptHeader(Constants.Http.MimeTypeJson)};
48+
49+
_context.Trace.WriteLine($"HTTP: HEAD {organizationUri}");
50+
using (var client = _httpFactory.GetClient())
51+
using (var response = await client.SendAsync(HttpMethod.Head, organizationUri, headers))
52+
{
53+
_context.Trace.WriteLine("HTTP: Response code ignored.");
54+
_context.Trace.WriteLine("Inspecting headers...");
55+
56+
// Check WWW-Authenticate headers first; we prefer these
57+
foreach (var header in response.Headers.WwwAuthenticate)
58+
{
59+
if (TryGetAuthorityFromHeader(header, out string authority))
60+
{
61+
_context.Trace.WriteLine(
62+
$"Found WWW-Authenticate header with Bearer authority '{authority}'.");
63+
return authority;
64+
}
65+
}
66+
67+
// We didn't find a bearer WWW-Auth header; check for the X-VSS-ResourceTenant header
68+
foreach (var header in response.Headers)
69+
{
70+
if (StringComparer.OrdinalIgnoreCase.Equals(header.Key, AzureDevOpsConstants.VssResourceTenantHeader))
71+
{
72+
string[] tenantIds = header.Value.ToArray();
73+
Guid guid;
74+
75+
// Take the first tenant ID that isn't an empty GUID
76+
var tenantId = tenantIds.FirstOrDefault(x => Guid.TryParse(x, out guid) && guid != Guid.Empty);
77+
if (tenantId != null)
78+
{
79+
_context.Trace.WriteLine($"Found {AzureDevOpsConstants.VssResourceTenantHeader} header with AAD tenant ID '{tenantId}'.");
80+
return authorityBase + tenantId;
81+
}
82+
83+
// If we have exactly one empty GUID then this is a MSA backed organization
84+
if (tenantIds.Length == 1 && Guid.TryParse(tenantIds[0], out guid) && guid == Guid.Empty)
85+
{
86+
_context.Trace.WriteLine($"Found {AzureDevOpsConstants.VssResourceTenantHeader} header with MSA tenant ID (empty GUID).");
87+
return msaAuthority;
88+
}
89+
}
90+
}
91+
}
92+
93+
// Use the common authority if we can't determine a specific one
94+
_context.Trace.WriteLine("Falling back to common authority.");
95+
return commonAuthority;
96+
}
97+
98+
public async Task<string> CreatePersonalAccessTokenAsync(Uri organizationUri, string accessToken, IEnumerable<string> scopes)
99+
{
100+
const string sessionTokenUrl = "_apis/token/sessiontokens?api-version=1.0&tokentype=compact";
101+
102+
EnsureArgument.AbsoluteUri(organizationUri, nameof(organizationUri));
103+
if (!UriHelpers.IsAzureDevOpsHost(organizationUri.Host))
104+
{
105+
throw new ArgumentException($"Provided URI '{organizationUri}' is not a valid Azure DevOps hostname", nameof(organizationUri));
106+
}
107+
EnsureArgument.NotNullOrWhiteSpace(accessToken, nameof(accessToken));
108+
109+
_context.Trace.WriteLine("Getting Azure DevOps Identity Service endpoint...");
110+
Uri identityServiceUri = await GetIdentityServiceUriAsync(organizationUri, accessToken);
111+
_context.Trace.WriteLine($"Identity Service endpoint is '{identityServiceUri}'.");
112+
113+
Uri requestUri = new Uri(identityServiceUri, sessionTokenUrl);
114+
115+
var headers = new[]
116+
{
117+
Constants.Http.AcceptHeader(Constants.Http.MimeTypeJson),
118+
Constants.Http.AuthorizationBearerHeader(accessToken)
119+
};
120+
121+
_context.Trace.WriteLine($"HTTP: POST {requestUri}");
122+
using (StringContent content = CreateAccessTokenRequestJson(organizationUri, scopes))
123+
using (var client = _httpFactory.GetClient())
124+
using (var response = await client.SendAsync(HttpMethod.Post, requestUri, headers, content))
125+
{
126+
_context.Trace.WriteLine($"HTTP: Response {(int)response.StatusCode} [{response.StatusCode}]");
127+
128+
string responseText = await response.Content.ReadAsStringAsync();
129+
130+
if (!string.IsNullOrWhiteSpace(responseText))
131+
{
132+
if (response.IsSuccessStatusCode)
133+
{
134+
if (TryGetFirstJsonStringField(responseText, "token", out string token))
135+
{
136+
return token;
137+
}
138+
}
139+
else
140+
{
141+
if (TryGetFirstJsonStringField(responseText, "message", out string errorMessage))
142+
{
143+
throw new Exception($"Failed to create PAT: {errorMessage}");
144+
}
145+
}
146+
}
147+
}
148+
149+
throw new Exception("Failed to create PAT");
150+
}
151+
152+
#region Private Methods
153+
154+
private async Task<Uri> GetIdentityServiceUriAsync(Uri organizationUri, string accessToken)
155+
{
156+
const string locationServicePath = "_apis/ServiceDefinitions/LocationService2/951917AC-A960-4999-8464-E3F0AA25B381";
157+
const string locationServiceQuery = "api-version=1.0";
158+
159+
Uri requestUri = new UriBuilder(organizationUri)
160+
{
161+
Path = UriHelpers.CombinePath(organizationUri.AbsolutePath, locationServicePath),
162+
Query = locationServiceQuery,
163+
}.Uri;
164+
165+
var headers = new[]
166+
{
167+
Constants.Http.AcceptHeader(Constants.Http.MimeTypeJson),
168+
Constants.Http.AuthorizationBearerHeader(accessToken)
169+
};
170+
171+
_context.Trace.WriteLine($"HTTP: GET {requestUri}");
172+
using (var client = _httpFactory.GetClient())
173+
using (var response = await client.SendAsync(HttpMethod.Get, requestUri, headers))
174+
{
175+
_context.Trace.WriteLine($"HTTP: Response {(int)response.StatusCode} [{response.StatusCode}]");
176+
if (response.IsSuccessStatusCode)
177+
{
178+
string responseText = await response.Content.ReadAsStringAsync();
179+
180+
if (TryGetFirstJsonStringField(responseText, "location", out string identityServiceStr) &&
181+
Uri.TryCreate(identityServiceStr, UriKind.Absolute, out Uri identityService))
182+
{
183+
return identityService;
184+
}
185+
}
186+
}
187+
188+
throw new Exception("Failed to find location service");
189+
}
190+
191+
#endregion
192+
193+
#region Request and Response Helpers
194+
195+
private const RegexOptions CommonRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase;
196+
197+
/// <summary>
198+
/// Attempt to extract the authority from a Authorization Bearer header.
199+
/// </summary>
200+
/// <remarks>This method has internal visibility for testing purposes only.</remarks>
201+
/// <param name="header">Request header</param>
202+
/// <param name="authority">Value of authorization authority, or null if not found.</param>
203+
/// <returns>True if an authority was found in the header, false otherwise.</returns>
204+
internal static bool TryGetAuthorityFromHeader(AuthenticationHeaderValue header, out string authority)
205+
{
206+
// We're looking for a "Bearer" scheme header
207+
if (!(header is null) &&
208+
StringComparer.OrdinalIgnoreCase.Equals(header.Scheme, Constants.Http.WwwAuthenticateBearerScheme) &&
209+
header.Parameter is string headerValue)
210+
{
211+
Match match = Regex.Match(headerValue, @"^authorization_uri=(?'authority'.+)$", CommonRegexOptions);
212+
213+
if (match.Success)
214+
{
215+
authority = match.Groups["authority"].Value;
216+
return true;
217+
}
218+
}
219+
220+
authority = null;
221+
return false;
222+
}
223+
224+
/// <summary>
225+
/// Parse the input JSON string looking for the first string field with the specified name.
226+
/// </summary>
227+
/// <remarks>This method has internal visibility for testing purposes only.</remarks>
228+
/// <param name="json">JSON string</param>
229+
/// <param name="fieldName">Name of field to locate.</param>
230+
/// <param name="value">Value of first found field, or null if no such field was found.</param>
231+
/// <returns>True if a field and value was found, false otherwise.</returns>
232+
internal static bool TryGetFirstJsonStringField(string json, string fieldName, out string value)
233+
{
234+
if (!string.IsNullOrWhiteSpace(json))
235+
{
236+
// Find the '"<field>" : "<value>"' portion of the JSON content
237+
string escapedFieldName = Regex.Escape(fieldName);
238+
string pattern = $"\"{escapedFieldName}\"\\s*\\:\\s*\"(?'value'[^\"]+)\"";
239+
Match match = Regex.Match(json, pattern, CommonRegexOptions);
240+
if (match.Success)
241+
{
242+
value = match.Groups["value"].Value;
243+
return true;
244+
}
245+
246+
}
247+
248+
value = null;
249+
return false;
250+
}
251+
252+
/// <summary>
253+
/// Create the JSON request body content used to request a new personal access token be created
254+
/// with the specified scopes.
255+
/// </summary>
256+
/// <param name="organizationUri">Azure DevOps organization URL to create the token for.</param>
257+
/// <param name="tokenScopes">Scopes to request.</param>
258+
/// <returns>JSON request content.</returns>
259+
private static StringContent CreateAccessTokenRequestJson(Uri organizationUri, IEnumerable<string> tokenScopes)
260+
{
261+
const string jsonFormat = "{{ \"scope\" : \"{0}\", \"displayName\" : \"Git: {1} on {2}\" }}";
262+
263+
string orgUrl = organizationUri.AbsoluteUri;
264+
string joinedScopes = string.Join(" ", tokenScopes);
265+
string machineName = Environment.MachineName;
266+
267+
string jsonContent = string.Format(jsonFormat, joinedScopes, orgUrl, machineName);
268+
var content = new StringContent(jsonContent, Encoding.UTF8, Constants.Http.MimeTypeJson);
269+
270+
return content;
271+
}
272+
273+
#endregion
274+
}
275+
}

0 commit comments

Comments
 (0)