static bool IsAzureDevOpsHttpRemote(string url, out Uri uri) { return(Uri.TryCreate(url, UriKind.Absolute, out uri) && (StringComparer.OrdinalIgnoreCase.Equals(Uri.UriSchemeHttp, uri.Scheme) || StringComparer.OrdinalIgnoreCase.Equals(Uri.UriSchemeHttps, uri.Scheme)) && UriHelpers.IsAzureDevOpsHost(uri.Host)); }
private static string GetAccountNameForCredentialQuery(InputArguments input) { if (!input.TryGetHostAndPort(out string hostName, out _)) { throw new InvalidOperationException("Failed to parse host name and/or port"); } // dev.azure.com if (UriHelpers.IsDevAzureComHost(hostName)) { // We ignore the given username for dev.azure.com-style URLs because AzDevOps recommends // adding the organization name as the user in the remote URL (resulting in URLs like // https://[email protected]/org/foo/_git/bar) and we don't know if the given username // is an actual username, or the org name. // Use `null` as the account name so we match all possible credentials (regardless of // the account). return(null); } // *.visualstudio.com if (UriHelpers.IsVisualStudioComHost(hostName)) { // If we're given a username for the vs.com-style URLs we can and should respect any // specified username in the remote URL/input arguments. return(input.UserName); } throw new InvalidOperationException("Host is not Azure DevOps."); }
public Task EraseCredentialAsync(InputArguments input) { Uri remoteUri = input.GetRemoteUri(); if (UsePersonalAccessTokens()) { string service = GetServiceName(remoteUri); string account = GetAccountNameForCredentialQuery(input); // Try to locate an existing credential _context.Trace.WriteLine($"Erasing stored credential in store with service={service} account={account}..."); if (_context.CredentialStore.Remove(service, account)) { _context.Trace.WriteLine("Credential was successfully erased."); } else { _context.Trace.WriteLine("No credential was erased."); } } else { string orgName = UriHelpers.GetOrganizationName(remoteUri); _context.Trace.WriteLine($"Signing out of organization '{orgName}'..."); _bindingManager.SignOut(orgName); // Clear the authority cache in case this was the reason for failure _authorityCache.EraseAuthority(orgName); } return(Task.CompletedTask); }
public Task StoreCredentialAsync(InputArguments input) { Uri remoteUri = input.GetRemoteUri(); if (UsePersonalAccessTokens()) { string service = GetServiceName(remoteUri); // We always store credentials against the given username argument for // both vs.com and dev.azure.com-style URLs. string account = input.UserName; // Add or update the credential in the store. _context.Trace.WriteLine($"Storing credential with service={service} account={account}..."); _context.CredentialStore.AddOrUpdate(service, account, input.Password); _context.Trace.WriteLine("Credential was successfully stored."); } else { string orgName = UriHelpers.GetOrganizationName(remoteUri); _context.Trace.WriteLine($"Signing user {input.UserName} in to organization '{orgName}'..."); _bindingManager.SignIn(orgName, input.UserName); } return(Task.CompletedTask); }
private async Task <Uri> GetIdentityServiceUriAsync(Uri organizationUri, JsonWebToken accessToken) { const string locationServicePath = "_apis/ServiceDefinitions/LocationService2/951917AC-A960-4999-8464-E3F0AA25B381"; const string locationServiceQuery = "api-version=1.0"; Uri requestUri = new UriBuilder(organizationUri) { Path = UriHelpers.CombinePath(organizationUri.AbsolutePath, locationServicePath), Query = locationServiceQuery, }.Uri; _context.Trace.WriteLine($"HTTP: GET {requestUri}"); using (HttpRequestMessage request = CreateRequestMessage(HttpMethod.Get, requestUri, bearerToken: accessToken)) using (HttpResponseMessage response = await HttpClient.SendAsync(request)) { _context.Trace.WriteLine($"HTTP: Response {(int)response.StatusCode} [{response.StatusCode}]"); if (response.IsSuccessStatusCode) { string responseText = await response.Content.ReadAsStringAsync(); if (TryGetFirstJsonStringField(responseText, "location", out string identityServiceStr) && Uri.TryCreate(identityServiceStr, UriKind.Absolute, out Uri identityService)) { return(identityService); } } } throw new Exception("Failed to find location service"); }
/// <remarks> /// For dev.azure.com-style URLs we use the path arg to get the Azure DevOps organization name. /// We ensure the presence of the path arg by setting credential.useHttpPath = true at install time. /// /// The result of this workaround is that we are now unable to determine if the user wanted to store /// credentials with the full path or not for dev.azure.com-style URLs. /// /// Rather than always assume we're storing credentials against the full path, and therefore resulting /// in an personal access token being created per remote URL/repository, we never store against /// the full path and always store with the organization URL "dev.azure.com/org". /// /// For visualstudio.com-style URLs we know the AzDevOps organization name from the host arg, and /// don't set the useHttpPath option. This means if we get the full path for a vs.com-style URL /// we can store against the full remote path (the intended design). /// /// Users that need to clone a repository from Azure Repos against the full path therefore must /// use the vs.com-style remote URL and not the dev.azure.com one. /// </remarks> private static string GetServiceName(InputArguments input) { if (!input.TryGetHostAndPort(out string hostName, out _)) { throw new InvalidOperationException("Failed to parse host name and/or port"); } // dev.azure.com if (UriHelpers.IsDevAzureComHost(hostName)) { // We can never store the new dev.azure.com-style URLs against the full path because // we have forced the useHttpPath option to true to in order to retrieve the AzDevOps // organization name from Git. return(UriHelpers.CreateOrganizationUri(input).AbsoluteUri.TrimEnd('/')); } // *.visualstudio.com if (UriHelpers.IsVisualStudioComHost(hostName)) { // If we're given the full path for an older *.visualstudio.com-style URL then we should // respect that in the service name. return(input.GetRemoteUri().AbsoluteUri.TrimEnd('/')); } throw new InvalidOperationException("Host is not Azure DevOps."); }
public override bool IsSupported(InputArguments input) { // We do not support unencrypted HTTP communications to Azure Repos, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. return((StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && UriHelpers.IsAzureDevOpsHost(input.Host)); }
public override async Task <ICredential> GenerateCredentialAsync(InputArguments input) { ThrowIfDisposed(); // We should not allow unencrypted communication and should inform the user if (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http")) { throw new Exception("Unencrypted HTTP is not supported for Azure Repos. Ensure the repository remote URL is using HTTPS."); } Uri orgUri = UriHelpers.CreateOrganizationUri(input); Uri remoteUri = input.GetRemoteUri(); // Determine the MS authentication authority for this organization Context.Trace.WriteLine("Determining Microsoft Authentication Authority..."); string authAuthority = await _azDevOps.GetAuthorityAsync(orgUri); Context.Trace.WriteLine($"Authority is '{authAuthority}'."); // Get an AAD access token for the Azure DevOps SPS Context.Trace.WriteLine("Getting Azure AD access token..."); JsonWebToken accessToken = await _msAuth.GetAccessTokenAsync( authAuthority, AzureDevOpsConstants.AadClientId, AzureDevOpsConstants.AadRedirectUri, AzureDevOpsConstants.AadResourceId, remoteUri, null); string atUser = accessToken.GetAzureUserName(); Context.Trace.WriteLineSecrets($"Acquired Azure access token. User='******' Token='{{0}}'", new object[] { accessToken.EncodedToken }); // Ask the Azure DevOps instance to create a new PAT var patScopes = new[] { AzureDevOpsConstants.PersonalAccessTokenScopes.ReposWrite, AzureDevOpsConstants.PersonalAccessTokenScopes.ArtifactsRead }; Context.Trace.WriteLine($"Creating Azure DevOps PAT with scopes '{string.Join(", ", patScopes)}'..."); string pat = await _azDevOps.CreatePersonalAccessTokenAsync( orgUri, accessToken, patScopes); Context.Trace.WriteLineSecrets("PAT created. PAT='{0}'", new object[] { pat }); return(new GitCredential(Constants.PersonalAccessTokenUserName, pat)); }
public bool IsSupported(InputArguments input) { if (input is null) { return(false); } // We do not support unencrypted HTTP communications to Azure Repos, // but we report `true` here for HTTP so that we can show a helpful // error message for the user in `CreateCredentialAsync`. return(input.TryGetHostAndPort(out string hostName, out _) && (StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "http") || StringComparer.OrdinalIgnoreCase.Equals(input.Protocol, "https")) && UriHelpers.IsAzureDevOpsHost(hostName)); }
public async Task <string> CreatePersonalAccessTokenAsync(Uri organizationUri, JsonWebToken accessToken, IEnumerable <string> scopes) { const string sessionTokenUrl = "_apis/token/sessiontokens?api-version=1.0&tokentype=compact"; EnsureArgument.AbsoluteUri(organizationUri, nameof(organizationUri)); if (!UriHelpers.IsAzureDevOpsHost(organizationUri.Host)) { throw new ArgumentException($"Provided URI '{organizationUri}' is not a valid Azure DevOps hostname", nameof(organizationUri)); } EnsureArgument.NotNull(accessToken, nameof(accessToken)); _context.Trace.WriteLine("Getting Azure DevOps Identity Service endpoint..."); Uri identityServiceUri = await GetIdentityServiceUriAsync(organizationUri, accessToken); _context.Trace.WriteLine($"Identity Service endpoint is '{identityServiceUri}'."); Uri requestUri = new Uri(identityServiceUri, sessionTokenUrl); _context.Trace.WriteLine($"HTTP: POST {requestUri}"); using (StringContent content = CreateAccessTokenRequestJson(organizationUri, scopes)) using (HttpRequestMessage request = CreateRequestMessage(HttpMethod.Post, requestUri, content, accessToken)) using (HttpResponseMessage response = await HttpClient.SendAsync(request)) { _context.Trace.WriteLine($"HTTP: Response {(int)response.StatusCode} [{response.StatusCode}]"); string responseText = await response.Content.ReadAsStringAsync(); if (!string.IsNullOrWhiteSpace(responseText)) { if (response.IsSuccessStatusCode) { if (TryGetFirstJsonStringField(responseText, "token", out string token)) { return(token); } } else { if (TryGetFirstJsonStringField(responseText, "message", out string errorMessage)) { throw new Exception($"Failed to create PAT: {errorMessage}"); } } } } throw new Exception("Failed to create PAT"); }
/// <remarks> /// For dev.azure.com-style URLs we use the path arg to get the Azure DevOps organization name. /// We ensure the presence of the path arg by setting credential.useHttpPath = true at install time. /// /// The result of this workaround is that we are now unable to determine if the user wanted to store /// credentials with the full path or not for dev.azure.com-style URLs. /// /// Rather than always assume we're storing credentials against the full path, and therefore resulting /// in an personal access token being created per remote URL/repository, we never store against /// the full path and always store with the organization URL "dev.azure.com/org". /// /// For visualstudio.com-style URLs we know the AzDevOps organization name from the host arg, and /// don't set the useHttpPath option. This means if we get the full path for a vs.com-style URL /// we can store against the full remote path (the intended design). /// /// Users that need to clone a repository from Azure Repos against the full path therefore must /// use the vs.com-style remote URL and not the dev.azure.com one. /// </remarks> private static string GetServiceName(Uri remoteUri) { // dev.azure.com if (UriHelpers.IsDevAzureComHost(remoteUri.Host)) { // We can never store the new dev.azure.com-style URLs against the full path because // we have forced the useHttpPath option to true to in order to retrieve the AzDevOps // organization name from Git. return(UriHelpers.CreateOrganizationUri(remoteUri, out _).AbsoluteUri.TrimEnd('/')); } // *.visualstudio.com if (UriHelpers.IsVisualStudioComHost(remoteUri.Host)) { // If we're given the full path for an older *.visualstudio.com-style URL then we should // respect that in the service name. return(remoteUri.AbsoluteUri.TrimEnd('/')); } throw new InvalidOperationException("Host is not Azure DevOps."); }
public override string GetCredentialKey(InputArguments input) { return($"git:{UriHelpers.CreateOrganizationUri(input).AbsoluteUri}"); }
private async Task <IMicrosoftAuthenticationResult> GetAzureAccessTokenAsync(Uri remoteUri, string userName) { // We should not allow unencrypted communication and should inform the user if (StringComparer.OrdinalIgnoreCase.Equals(remoteUri.Scheme, "http")) { throw new Exception("Unencrypted HTTP is not supported for Azure Repos. Ensure the repository remote URL is using HTTPS."); } Uri orgUri = UriHelpers.CreateOrganizationUri(remoteUri, out string orgName); _context.Trace.WriteLine($"Determining Microsoft Authentication authority for Azure DevOps organization '{orgName}'..."); string authAuthority = _authorityCache.GetAuthority(orgName); if (authAuthority is null) { // If there is no cached value we must query for it and cache it for future use _context.Trace.WriteLine($"No cached authority value - querying {orgUri} for authority..."); authAuthority = await _azDevOps.GetAuthorityAsync(orgUri); _authorityCache.UpdateAuthority(orgName, authAuthority); } _context.Trace.WriteLine($"Authority is '{authAuthority}'."); // // If the remote URI is a classic "*.visualstudio.com" host name and we have a user specified from the // remote then take that as the current AAD/MSA user in the first instance. // // For "dev.azure.com" host names we only use the user info part of the remote when this doesn't // match the Azure DevOps organization name. Our friends in Azure DevOps decided "borrow" the username // part of the remote URL to include the organization name (not an actual username). // // If we have no specified user from the remote (or this is [email protected]/org/..) then query the // user manager for a bound user for this organization, if one exists... // var icmp = StringComparer.OrdinalIgnoreCase; if (!string.IsNullOrWhiteSpace(userName) && (UriHelpers.IsVisualStudioComHost(remoteUri.Host) || (UriHelpers.IsAzureDevOpsHost(remoteUri.Host) && !icmp.Equals(orgName, userName)))) { _context.Trace.WriteLine("Using username as specified in remote."); } else { _context.Trace.WriteLine($"Looking up user for organization '{orgName}'..."); userName = _bindingManager.GetUser(orgName); } _context.Trace.WriteLine(string.IsNullOrWhiteSpace(userName) ? "No user found." : $"User is '{userName}'."); // Get an AAD access token for the Azure DevOps SPS _context.Trace.WriteLine("Getting Azure AD access token..."); IMicrosoftAuthenticationResult result = await _msAuth.GetTokenAsync( authAuthority, GetClientId(), GetRedirectUri(), AzureDevOpsConstants.AzureDevOpsDefaultScopes, userName); _context.Trace.WriteLineSecrets( $"Acquired Azure access token. Account='{result.AccountUpn}' Token='{{0}}'", new object[] { result.AccessToken }); return(result); }