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");
        }
Exemplo n.º 6
0
        /// <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);
        }