From 12d2de6607c3610e949f4aacfe0a73c60ce25ec0 Mon Sep 17 00:00:00 2001 From: Roman Vanicek Date: Thu, 20 Mar 2025 22:24:55 +0100 Subject: [PATCH] Initial implementation --- .gitignore | 2 + .vscode/launch.json | 15 ++ GetAuthenticationCredentialsRequestHandler.cs | 60 ++++++++ GetOperationClaimsRequestHandler.cs | 20 +++ GitTokenCredentialProvider.cs | 132 ++++++++++++++++ ICredentialProvider.cs | 18 +++ InitializeRequestHandler.cs | 12 ++ NugetSecretCredential.csproj | 13 ++ NugetSecretCredential.sln | 24 +++ Program.cs | 145 ++++++++++++++++++ RequestHandlerBase.cs | 40 +++++ RequestHandlerCollection.cs | 28 ++++ SetCredentialsRequestHandler.cs | 15 ++ SetLogLevelRequestHandler.cs | 14 ++ 14 files changed, 538 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 GetAuthenticationCredentialsRequestHandler.cs create mode 100644 GetOperationClaimsRequestHandler.cs create mode 100644 GitTokenCredentialProvider.cs create mode 100644 ICredentialProvider.cs create mode 100644 InitializeRequestHandler.cs create mode 100644 NugetSecretCredential.csproj create mode 100644 NugetSecretCredential.sln create mode 100644 Program.cs create mode 100644 RequestHandlerBase.cs create mode 100644 RequestHandlerCollection.cs create mode 100644 SetCredentialsRequestHandler.cs create mode 100644 SetLogLevelRequestHandler.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e9693e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +obj +bin \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5b95d06 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Launch", + "type": "coreclr", + "request": "launch", + "program": "${workspaceRoot}/bin/Debug/net9.0/NugetSecretCredential.dll", + "args": ["-U", "https://www.example.org"] + } + ] +} \ No newline at end of file diff --git a/GetAuthenticationCredentialsRequestHandler.cs b/GetAuthenticationCredentialsRequestHandler.cs new file mode 100644 index 0000000..c1cfcc5 --- /dev/null +++ b/GetAuthenticationCredentialsRequestHandler.cs @@ -0,0 +1,60 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal class GetAuthenticationCredentialsRequestHandler : RequestHandlerBase +{ + private readonly IReadOnlyCollection _credentialProviders; + private readonly TimeSpan progressReporterTimeSpan = TimeSpan.FromSeconds(2); + + public GetAuthenticationCredentialsRequestHandler(IReadOnlyCollection credentialProviders) + { + this._credentialProviders = credentialProviders ?? throw new ArgumentNullException(); + } + + public override async Task HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancel) + { + if (request?.Uri == null) + return new GetAuthenticationCredentialsResponse( + username: null, + password: null, + message: "Uri is null", + authenticationTypes: null, + responseCode: MessageResponseCode.Error); + + foreach (ICredentialProvider credentialProvider in _credentialProviders) + { + if (await credentialProvider.CanProvideCredentialsAsync(request.Uri, cancel) == false) + continue; + + try + { + GetAuthenticationCredentialsResponse response = await credentialProvider.HandleRequestAsync(request, cancel); + if (response != null && response.ResponseCode == MessageResponseCode.Success) + return response; + } + catch (Exception e) + { + return new GetAuthenticationCredentialsResponse( + username: null, + password: null, + message: e.Message, + authenticationTypes: null, + responseCode: MessageResponseCode.Error); + } + } + + return new GetAuthenticationCredentialsResponse( + username: null, + password: null, + message: null, + authenticationTypes: null, + responseCode: MessageResponseCode.NotFound); + } + + protected override AutomaticProgressReporter GetProgressReporter(IConnection connection, Message message, CancellationToken cancellationToken) + { + return AutomaticProgressReporter.Create(connection, message, progressReporterTimeSpan, cancellationToken); + } +} \ No newline at end of file diff --git a/GetOperationClaimsRequestHandler.cs b/GetOperationClaimsRequestHandler.cs new file mode 100644 index 0000000..ed6b16e --- /dev/null +++ b/GetOperationClaimsRequestHandler.cs @@ -0,0 +1,20 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal class GetOperationClaimsRequestHandler : RequestHandlerBase +{ + private static readonly GetOperationClaimsResponse CanProvideCredentialsResponse = + new GetOperationClaimsResponse(new List { OperationClaim.Authentication }); + + private static readonly GetOperationClaimsResponse EmptyGetOperationClaimsResponse = + new GetOperationClaimsResponse(new List()); + + public override Task HandleRequestAsync(GetOperationClaimsRequest request, CancellationToken cancel) + { + return request.PackageSourceRepository != null || request.ServiceIndex != null + ? Task.FromResult(EmptyGetOperationClaimsResponse) + : Task.FromResult(CanProvideCredentialsResponse); + } +} \ No newline at end of file diff --git a/GitTokenCredentialProvider.cs b/GitTokenCredentialProvider.cs new file mode 100644 index 0000000..26ee4de --- /dev/null +++ b/GitTokenCredentialProvider.cs @@ -0,0 +1,132 @@ +using System; +using System.Diagnostics; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +sealed class GitTokenCredentialProvider : ICredentialProvider +{ + private const string TokenUserName = "nugetToken"; // can be anything constant + + private const int MaxCredentialHelperTimeout = 2000; + + private static Lazy> _credentialHelper = new Lazy>(DetermineGitCredentialHelper); + + public async Task CanProvideCredentialsAsync(Uri uri, CancellationToken cancel) + { + Task credHelper; + if (!_credentialHelper.IsValueCreated || !_credentialHelper.Value.IsCompleted) + { + TaskCompletionSource cancelTrigger = new TaskCompletionSource(); + using (CancellationTokenRegistration cancelReg = cancel.Register(() => cancelTrigger.SetResult())) + { + credHelper = _credentialHelper.Value; + Task t = await Task.WhenAny(credHelper, cancelTrigger.Task); + if (t != credHelper) + throw new TaskCanceledException(); + } + } + else + credHelper = _credentialHelper.Value; + + return credHelper.Result != null; + } + + public async Task HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancel) + { + ProcessStartInfo psiHelper = new ProcessStartInfo(); + psiHelper.FileName = _credentialHelper.Value.Result; + psiHelper.ArgumentList.Add("get"); + psiHelper.RedirectStandardInput = true; + psiHelper.RedirectStandardOutput = true; + Process? pHelper = Process.Start(psiHelper); + if (pHelper == null) + // Credential helper executable not found + return new GetAuthenticationCredentialsResponse(null, null, null, null, responseCode: MessageResponseCode.Error); + + pHelper.StandardInput.WriteLine("protocol=https"); + pHelper.StandardInput.WriteLine("username=s" + TokenUserName); + pHelper.StandardInput.WriteLine("host=" + request.Uri.DnsSafeHost); + pHelper.StandardInput.WriteLine(); + + await pHelper.WaitForExitAsync(cancel); + + string? line; + while ((line = pHelper.StandardOutput.ReadLine()) != null) + { + const string PasswordKey = "password="; + if (line.StartsWith(PasswordKey)) + // Password found + return new GetAuthenticationCredentialsResponse( + TokenUserName, line.Substring(PasswordKey.Length), null, + new List { "Basic" }, MessageResponseCode.Success + ); + } + + // Password not found (we may be allowed to ask for it interactively) + if (request.CanShowDialog) + { + ProcessStartInfo psiDialog = new ProcessStartInfo(); + psiDialog.FileName = "zenity"; + foreach (string i in (IEnumerable)["--forms", "--title", "Nuget repository", "--text", request.Uri.DnsSafeHost, "--add-password=Token"]) + psiDialog.ArgumentList.Add(i); + psiDialog.RedirectStandardOutput = true; + Process? pDialog = Process.Start(psiDialog); + if (pDialog != null) + { + // Zenity dialog executable found + await pDialog.WaitForExitAsync(cancel); + string? password = pDialog.StandardOutput.ReadLine(); + if (password != null && password.Length != 0 && pDialog.StandardOutput.ReadLine() == null) + { + // We have a new password, persist it also + ProcessStartInfo psiHelperStore = new ProcessStartInfo(); + psiHelperStore.FileName = _credentialHelper.Value.Result; + psiHelperStore.ArgumentList.Add("store"); + psiHelperStore.RedirectStandardInput = true; + Process? pHelperStore = Process.Start(psiHelperStore); + if (pHelperStore == null) + // Credential helper executable not found + return new GetAuthenticationCredentialsResponse(null, null, null, null, responseCode: MessageResponseCode.Error); + + pHelperStore.StandardInput.WriteLine("protocol=https"); + pHelperStore.StandardInput.WriteLine("username=" + TokenUserName); + pHelperStore.StandardInput.WriteLine("password=" + password); + pHelperStore.StandardInput.WriteLine("host=" + request.Uri.DnsSafeHost); + pHelper.StandardInput.WriteLine(); + await pHelperStore.WaitForExitAsync(cancel); + + return new GetAuthenticationCredentialsResponse( + TokenUserName, password, null, + new List { "Basic" }, MessageResponseCode.Success + ); + } + } + } + + return new GetAuthenticationCredentialsResponse(null, null, null, null, responseCode: MessageResponseCode.NotFound); + } + + private static async Task DetermineGitCredentialHelper() + { + CancellationTokenSource cancel = new CancellationTokenSource(MaxCredentialHelperTimeout); + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = "git"; + psi.ArgumentList.Add("config"); + psi.ArgumentList.Add("--global"); + psi.ArgumentList.Add("credential.helper"); + psi.RedirectStandardOutput = true; + Process? p = Process.Start(psi); + if (p == null) + // Git executable not found + return null; + + await p.WaitForExitAsync(cancel.Token); + string? result = p.StandardOutput.ReadLine(); + if (p.StandardOutput.ReadLine() != null) + // Expected a single line with the credential helper executable path + return null; + + return result; + } +} diff --git a/ICredentialProvider.cs b/ICredentialProvider.cs new file mode 100644 index 0000000..a1fb3cb --- /dev/null +++ b/ICredentialProvider.cs @@ -0,0 +1,18 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal interface ICredentialProvider +{ + /// + /// Checks if implementation can provide credentials. + /// + /// The of the target. + Task CanProvideCredentialsAsync(Uri uri, CancellationToken cancel); + + /// + /// Handle credential request. + /// + Task HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancellationToken); +} diff --git a/InitializeRequestHandler.cs b/InitializeRequestHandler.cs new file mode 100644 index 0000000..01fdea6 --- /dev/null +++ b/InitializeRequestHandler.cs @@ -0,0 +1,12 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal class InitializeRequestHandler : RequestHandlerBase +{ + public override Task HandleRequestAsync(InitializeRequest request, CancellationToken cancel) + { + return Task.FromResult(new InitializeResponse(MessageResponseCode.Success)); + } +} \ No newline at end of file diff --git a/NugetSecretCredential.csproj b/NugetSecretCredential.csproj new file mode 100644 index 0000000..c4f183c --- /dev/null +++ b/NugetSecretCredential.csproj @@ -0,0 +1,13 @@ + + + + Exe + net9.0 + enable + enable + + + + + + diff --git a/NugetSecretCredential.sln b/NugetSecretCredential.sln new file mode 100644 index 0000000..844e019 --- /dev/null +++ b/NugetSecretCredential.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NugetSecretCredential", "NugetSecretCredential.csproj", "{E0BA52C4-F4D2-9748-5150-5C8C38E7E84A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E0BA52C4-F4D2-9748-5150-5C8C38E7E84A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0BA52C4-F4D2-9748-5150-5C8C38E7E84A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0BA52C4-F4D2-9748-5150-5C8C38E7E84A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0BA52C4-F4D2-9748-5150-5C8C38E7E84A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {5D4949AB-9A45-4602-9433-7372AE5554B8} + EndGlobalSection +EndGlobal diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..22131a7 --- /dev/null +++ b/Program.cs @@ -0,0 +1,145 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +public static class Program +{ + private static bool _shuttingDown = false; + public static bool IsShuttingDown => Volatile.Read(ref _shuttingDown); + + public static async Task Main(string[] args) + { + File.WriteAllText("/home/rv/temp/nsc.log", "Starting"); + + CancellationTokenSource tokenSource = new CancellationTokenSource(); + + List credentialProviders = new List + { + new GitTokenCredentialProvider(), + }; + + try + { + bool isPlugin = false; + bool isRetry = false; + bool isNonInteractive = false; + bool canShowDialog = false; + Uri? uri = null; + for (int i = 0; i < args.Length; i++) + switch (args[i].ToLower()) + { + case "-p": case "-plugin": isPlugin = true; break; + case "-i": case "-isretry": isRetry = true; break; + case "-n": case "-noninteractive": isNonInteractive = true; break; + case "-c": case "-canshowdialog": canShowDialog = true; break; + case "-u": case "-uri": + if (i + 1 == args.Length) + throw new ArgumentException("Expected uri after -U"); + if (!Uri.TryCreate(args[i + 1], UriKind.Absolute, out uri)) + throw new ArgumentException("Invalid uri format"); + break; + } + + IRequestHandlers requestHandlers = new RequestHandlerCollection + { + { MessageMethod.GetAuthenticationCredentials, new GetAuthenticationCredentialsRequestHandler(credentialProviders) }, + { MessageMethod.GetOperationClaims, new GetOperationClaimsRequestHandler() }, + { MessageMethod.Initialize, new InitializeRequestHandler() }, + { MessageMethod.SetLogLevel, new SetLogLevelRequestHandler() }, + { MessageMethod.SetCredentials, new SetCredentialsRequestHandler() }, + }; + + if (isPlugin) + { + // Plugin mode + try + { + using (IPlugin plugin = await PluginFactory.CreateFromCurrentProcessAsync(requestHandlers, ConnectionOptions.CreateDefault(), tokenSource.Token)) + { + await WaitForPluginExitAsync(plugin, TimeSpan.FromMinutes(2)).ConfigureAwait(continueOnCapturedContext: false); + } + } + catch (OperationCanceledException ex) + { + // When restoring from multiple sources, one of the sources will throw an unhandled TaskCanceledException + // if it has been restored successfully from a different source. + + // This is probably more confusing than interesting to users, but may be helpful in debugging, + // so log the exception but not to the console. + } + + return 0; + } + else + { + // Stand-alone mode + if (requestHandlers.TryGet(MessageMethod.GetAuthenticationCredentials, out IRequestHandler requestHandler) && requestHandler is GetAuthenticationCredentialsRequestHandler getAuthenticationCredentialsRequestHandler) + { + if (uri == null) + { + Console.WriteLine("Uri argument -U not provided"); + return 1; + } + + GetAuthenticationCredentialsRequest request = new GetAuthenticationCredentialsRequest(uri, isRetry: isRetry, isNonInteractive, canShowDialog); + GetAuthenticationCredentialsResponse response = await getAuthenticationCredentialsRequestHandler.HandleRequestAsync(request, default); + + // Fail if credentials are not found + if (response?.ResponseCode != MessageResponseCode.Success) + { + return 2; + } + + Console.WriteLine("username=" + response.Username); + Console.WriteLine("password=" + response.Password); + return 0; + } + + return -1; + } + + } + catch (Exception e) + { + Console.Error.WriteLine("Error " + e.GetType().Name + ": " + e.Message); + return 1; + } + } + + internal static async Task WaitForPluginExitAsync(IPlugin plugin, TimeSpan shutdownTimeout) + { + var beginShutdownTaskSource = new TaskCompletionSource(); + var endShutdownTaskSource = new TaskCompletionSource(); + + plugin.Connection.Faulted += (sender, a) => + { + Console.WriteLine(a.Exception.ToString()); + }; + + plugin.BeforeClose += (sender, args) => + { + Volatile.Write(ref _shuttingDown, true); + beginShutdownTaskSource.TrySetResult(null); + }; + + plugin.Closed += (sender, a) => + { + // beginShutdownTaskSource should already be set in BeforeClose, but just in case do it here too + beginShutdownTaskSource.TrySetResult(null); + + endShutdownTaskSource.TrySetResult(null); + }; + + await beginShutdownTaskSource.Task; + using (new Timer(_ => endShutdownTaskSource.TrySetCanceled(), null, shutdownTimeout, TimeSpan.FromMilliseconds(-1))) + { + await endShutdownTaskSource.Task; + } + + if (endShutdownTaskSource.Task.IsCanceled) + { + Console.WriteLine("Plugin timeout"); + } + } +} diff --git a/RequestHandlerBase.cs b/RequestHandlerBase.cs new file mode 100644 index 0000000..7a2824b --- /dev/null +++ b/RequestHandlerBase.cs @@ -0,0 +1,40 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal abstract class RequestHandlerBase : IRequestHandler + where TResponse : class +{ + protected RequestHandlerBase() + { + } + + CancellationToken IRequestHandler.CancellationToken => default; + + public IConnection? Connection { get; private set; } + + public virtual CancellationToken CancellationToken { get; private set; } = CancellationToken.None; + + public async Task HandleResponseAsync(IConnection connection, Message message, IResponseHandler responseHandler, CancellationToken cancel) + { + Connection = connection; + + TRequest request = MessageUtilities.DeserializePayload(message); + + TResponse? response = null; + using (GetProgressReporter(connection, message, cancel)) + { + response = await HandleRequestAsync(request, cancel); + } + // If we did not send a cancel message, we must submit the response even if cancellationToken is canceled. + await responseHandler.SendResponseAsync(message, response, CancellationToken.None); + } + + public abstract Task HandleRequestAsync(TRequest request, CancellationToken cancel); + + protected virtual AutomaticProgressReporter? GetProgressReporter(IConnection connection, Message message, CancellationToken cancel) + { + return null; + } +} \ No newline at end of file diff --git a/RequestHandlerCollection.cs b/RequestHandlerCollection.cs new file mode 100644 index 0000000..f5443e9 --- /dev/null +++ b/RequestHandlerCollection.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Concurrent; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal class RequestHandlerCollection : ConcurrentDictionary, IRequestHandlers +{ + public void Add(MessageMethod method, IRequestHandler handler) + { + TryAdd(method, handler); + } + + public void AddOrUpdate(MessageMethod method, Func addHandlerFunc, Func updateHandlerFunc) + { + AddOrUpdate(method, messageMethod => addHandlerFunc(), (messageMethod, requestHandler) => updateHandlerFunc(requestHandler)); + } + + public bool TryGet(MessageMethod method, out IRequestHandler requestHandler) + { + return TryGetValue(method, out requestHandler); + } + + public bool TryRemove(MessageMethod method) + { + return TryRemove(method, out IRequestHandler _); + } +} diff --git a/SetCredentialsRequestHandler.cs b/SetCredentialsRequestHandler.cs new file mode 100644 index 0000000..0da90bb --- /dev/null +++ b/SetCredentialsRequestHandler.cs @@ -0,0 +1,15 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal class SetCredentialsRequestHandler : RequestHandlerBase +{ + private static readonly SetCredentialsResponse SuccessResponse = new SetCredentialsResponse(MessageResponseCode.Success); + + public override Task HandleRequestAsync(SetCredentialsRequest request, CancellationToken cancel) + { + // There's currently no way to handle proxies, so nothing we can do here + return Task.FromResult(SuccessResponse); + } +} \ No newline at end of file diff --git a/SetLogLevelRequestHandler.cs b/SetLogLevelRequestHandler.cs new file mode 100644 index 0000000..5e77da6 --- /dev/null +++ b/SetLogLevelRequestHandler.cs @@ -0,0 +1,14 @@ +using System; +using NuGet.Protocol.Plugins; + +namespace NugetSecretCredential; + +internal class SetLogLevelRequestHandler : RequestHandlerBase +{ + private static readonly SetLogLevelResponse SuccessResponse = new SetLogLevelResponse(MessageResponseCode.Success); + + public override Task HandleRequestAsync(SetLogLevelRequest request, CancellationToken cancel) + { + return Task.FromResult(SuccessResponse); + } +}