File: PluginCredentialProvider.cs
Web Access
Project: src\src\nuget-client\src\NuGet.Core\NuGet.Credentials\NuGet.Credentials.csproj (NuGet.Credentials)
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

#nullable disable

using System;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using NuGet.Common;
using NuGet.Configuration;

namespace NuGet.Credentials
{
    /// <summary>
    /// Provider that handles calling command line credential providers
    /// </summary>
    public class PluginCredentialProvider : ICredentialProvider
    {
        private readonly Common.ILogger _logger;
        private readonly string _verbosity;
        private const string NormalVerbosity = "normal";
        private const string CrossPlatformPluginLink = "https://aka.ms/nuget-cross-platform-authentication-plugin";
        private int _deprecationMessageWarningLogged;

        /// <summary>
        /// Constructor
        /// </summary>
        /// <param name="logger">IConsole logger to use for debug logging. No secrets should ever be written to this log.</param>
        /// <param name="path">Fully qualified plugin application path.</param>
        /// <param name="timeoutSeconds">Max timeout to wait for the plugin application
        /// to return credentials.</param>
        /// <param name="verbosity">Verbosity string to pass to the plugin.</param>
        public PluginCredentialProvider(Common.ILogger logger, string path, int timeoutSeconds, string verbosity)
        {
            if (logger == null)
            {
                throw new ArgumentNullException(nameof(logger));
            }

            if (path == null)
            {
                throw new ArgumentNullException(nameof(path));
            }

            if (verbosity == null)
            {
                throw new ArgumentNullException(nameof(verbosity));
            }

            _logger = logger;
            _verbosity = verbosity;
            Path = path;
            TimeoutSeconds = timeoutSeconds;
            var filename = System.IO.Path.GetFileName(path);
            Id = $"{typeof(PluginCredentialProvider).Name}_{filename}_{Guid.NewGuid()}";
        }

        /// <summary>
        /// Unique identifier of this credential provider
        /// </summary>
        public string Id { get; }

        /// <summary>
        /// Call the plugin credential provider application to acquire credentials.
        /// The request will be passed to the plugin on standard input as a json serialized
        /// PluginCredentialRequest.
        /// The plugin will return credentials as a json serialized PluginCredentialResponse.
        /// Valid credentials will be returned, or null if the provide cannot provide credentials
        /// for the given request.  If the plugin returns an Abort message, an exception will be thrown to
        /// fail the current request.
        /// </summary>
        /// <param name="uri">The uri of a web resource for which credentials are needed.</param>
        /// <param name="proxy">Ignored.  Proxy information will not be passed to plugins.</param>
        /// <param name="type">
        /// The type of credential request that is being made. Note that this implementation of
        /// <see cref="ICredentialProvider"/> does not support providing proxy credenitials and treats
        /// all other types the same.
        /// </param>
        /// <param name="isRetry">If true, credentials were previously supplied by this
        /// provider for the same uri.</param>
        /// <param name="message">A message provided by NuGet to show to the user when prompting.</param>
        /// <param name="nonInteractive">If true, the plugin must not prompt for credentials.</param>
        /// <param name="cancellationToken">A cancellation token.</param>
        /// <returns>A credential object.</returns>
        public Task<CredentialResponse> GetAsync(
            Uri uri,
            IWebProxy proxy,
            CredentialRequestType type,
            string message,
            bool isRetry,
            bool nonInteractive,
            CancellationToken cancellationToken)
        {
            CredentialResponse taskResponse;
            if (type == CredentialRequestType.Proxy)
            {
                taskResponse = new CredentialResponse(CredentialStatus.ProviderNotApplicable);
                return Task.FromResult(taskResponse);
            }

            try
            {
                var request = new PluginCredentialRequest
                {
                    Uri = uri.AbsoluteUri,
                    IsRetry = isRetry,
                    NonInteractive = nonInteractive,
                    Verbosity = _verbosity
                };
                PluginCredentialResponse response;
                if (Interlocked.CompareExchange(ref _deprecationMessageWarningLogged, 1, 0) == 0)
                {
                    _logger.LogWarning(string.Format(CultureInfo.CurrentCulture, Resources.PluginWarning_PluginIsBeingDeprecated, Path, CrossPlatformPluginLink));
                }

                try
                {
                    response = GetPluginResponse(request, cancellationToken);
                }
                catch (PluginUnexpectedStatusException) when (PassVerbosityFlag(request))
                {
                    // older providers may throw if the verbosity flag is sent,
                    // so retry without it
                    request.Verbosity = null;
                    response = GetPluginResponse(request, cancellationToken);
                }

                if (response.IsValid)
                {
                    var result = new AuthTypeFilteredCredentials(
                        new NetworkCredential(response.Username, response.Password),
                        response.AuthTypes ?? Enumerable.Empty<string>());

                    taskResponse = new CredentialResponse(result);
                }
                else
                {
                    taskResponse = new CredentialResponse(CredentialStatus.ProviderNotApplicable);
                }
            }
            catch (OperationCanceledException)
            {
                throw;
            }
            catch (PluginException)
            {
                throw;
            }
            catch (Exception e)
            {
                throw PluginException.Create(Path, e);
            }

            return Task.FromResult(taskResponse);
        }

        /// <summary>
        /// Path to plugin credential provider executable.
        /// </summary>
        public string Path { get; }

        /// <summary>
        /// Seconds to wait for plugin credential service to respond.
        /// </summary>
        public int TimeoutSeconds { get; }

        private PluginCredentialResponse GetPluginResponse(PluginCredentialRequest request,
            CancellationToken cancellationToken)
        {
            var argumentString =
                $"-uri {request.Uri}"
                + (request.IsRetry ? " -isRetry" : string.Empty)
                + (request.NonInteractive ? " -nonInteractive" : string.Empty);

            // only apply -verbosity flag if set and != Normal
            // since normal is default
            if (PassVerbosityFlag(request))
            {
                argumentString += $" -verbosity {request.Verbosity.ToLower(CultureInfo.InvariantCulture)}";
            }

            var startInfo = new ProcessStartInfo
            {
                FileName = Path,
                Arguments = argumentString,
#if IS_DESKTOP                
                WindowStyle = ProcessWindowStyle.Hidden,
                ErrorDialog = false,
#endif
                UseShellExecute = false,
                RedirectStandardOutput = true,
                RedirectStandardError = true,
                StandardOutputEncoding = Encoding.UTF8,
                StandardErrorEncoding = Encoding.UTF8,
            };

            string stdOut = null;
            var exitCode = Execute(startInfo, cancellationToken, out stdOut);

            var status = (PluginCredentialResponseExitCode)exitCode;

            PluginCredentialResponse credentialResponse;
            try
            {
                // Mono will add utf-16 byte order mark to the start of stdOut, remove it here.
                credentialResponse =
                    JsonConvert.DeserializeObject<PluginCredentialResponse>(stdOut.Trim(new char[] { '\uFEFF' }))
                    ?? new PluginCredentialResponse();
            }
            catch (Exception)
            {
                // Do not expose stdout message, since it may contain credentials
                throw PluginException.CreateUnreadableResponseExceptionMessage(Path, status);
            }

            switch (status)
            {
                case PluginCredentialResponseExitCode.Success:
                    if (!credentialResponse.IsValid)
                    {
                        throw PluginException.CreateInvalidResponseExceptionMessage(
                            Path,
                            status,
                            credentialResponse);
                    }

                    return credentialResponse;

                case PluginCredentialResponseExitCode.ProviderNotApplicable:
                    credentialResponse.Username = null;
                    credentialResponse.Password = null;

                    return credentialResponse;

                case PluginCredentialResponseExitCode.Failure:
                    throw PluginException.CreateAbortMessage(Path, credentialResponse.Message);

                default:
                    throw PluginUnexpectedStatusException.CreateUnexpectedStatusMessage(Path, status);
            }
        }

#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
        public virtual int Execute(ProcessStartInfo startInfo, CancellationToken cancellationToken, out string stdOut)
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
        {
            var outBuffer = new StringBuilder();

            cancellationToken.ThrowIfCancellationRequested();

            using var process = Process.Start(startInfo);
            if (process == null)
            {
                throw PluginException.CreateNotStartedMessage(Path);
            }

            process.OutputDataReceived += (object o, DataReceivedEventArgs e) => { outBuffer.AppendLine(e.Data); };

            // Trace and error information may be written to standard error by the provider.
            // It should be logged at the Information level so it will appear if Verbosity >= Normal.
            process.ErrorDataReceived += (object o, DataReceivedEventArgs e) =>
            {
                if (!string.IsNullOrWhiteSpace(e?.Data))
                {
                    // This is a workaround for mono issue: https://github.com/NuGet/Home/issues/4004
                    if (!process.HasExited)
                    {
                        _logger.LogInformation($"{process.ProcessName}: {e.Data}");
                    }
                }
            };

            process.BeginOutputReadLine();
            process.BeginErrorReadLine();

            using (cancellationToken.Register(() => Kill(process)))
            {
                if (!process.WaitForExit(TimeoutSeconds * 1000))
                {
                    Kill(process);
                    throw PluginException.CreateTimeoutMessage(Path, TimeoutSeconds);
                }
                // Give time for the Async event handlers to finish by calling WaitForExit again.
                // if the first one succeeded
                // Note: Read remarks from https://msdn.microsoft.com/en-us/library/ty0d8k56(v=vs.110).aspx
                // for reason.
                process.WaitForExit();
            }

            process.CancelErrorRead();
            process.CancelOutputRead();
            cancellationToken.ThrowIfCancellationRequested();

            stdOut = outBuffer.ToString();
            return process.ExitCode;
        }

        private bool PassVerbosityFlag(PluginCredentialRequest request)
        {
            return request.Verbosity != null
                && !string.Equals(request.Verbosity, NormalVerbosity, StringComparison.OrdinalIgnoreCase);
        }

        private static void Kill(Process p)
        {
            if (p.HasExited)
            {
                return;
            }

            try
            {
                p.Kill();
            }
            catch (InvalidOperationException)
            {
                // the process may have exited, 
                // in this case ignore the exception
            }
        }
    }
}