Initial implementation

This commit is contained in:
2025-03-20 22:24:55 +01:00
commit 12d2de6607
14 changed files with 538 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
obj
bin

15
.vscode/launch.json vendored Normal file
View File

@@ -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"]
}
]
}

View File

@@ -0,0 +1,60 @@
using System;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal class GetAuthenticationCredentialsRequestHandler : RequestHandlerBase<GetAuthenticationCredentialsRequest, GetAuthenticationCredentialsResponse>
{
private readonly IReadOnlyCollection<ICredentialProvider> _credentialProviders;
private readonly TimeSpan progressReporterTimeSpan = TimeSpan.FromSeconds(2);
public GetAuthenticationCredentialsRequestHandler(IReadOnlyCollection<ICredentialProvider> credentialProviders)
{
this._credentialProviders = credentialProviders ?? throw new ArgumentNullException();
}
public override async Task<GetAuthenticationCredentialsResponse> 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);
}
}

View File

@@ -0,0 +1,20 @@
using System;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal class GetOperationClaimsRequestHandler : RequestHandlerBase<GetOperationClaimsRequest, GetOperationClaimsResponse>
{
private static readonly GetOperationClaimsResponse CanProvideCredentialsResponse =
new GetOperationClaimsResponse(new List<OperationClaim> { OperationClaim.Authentication });
private static readonly GetOperationClaimsResponse EmptyGetOperationClaimsResponse =
new GetOperationClaimsResponse(new List<OperationClaim>());
public override Task<GetOperationClaimsResponse> HandleRequestAsync(GetOperationClaimsRequest request, CancellationToken cancel)
{
return request.PackageSourceRepository != null || request.ServiceIndex != null
? Task.FromResult(EmptyGetOperationClaimsResponse)
: Task.FromResult(CanProvideCredentialsResponse);
}
}

View File

@@ -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<Task<string?>> _credentialHelper = new Lazy<Task<string?>>(DetermineGitCredentialHelper);
public async Task<bool> CanProvideCredentialsAsync(Uri uri, CancellationToken cancel)
{
Task<string?> 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<GetAuthenticationCredentialsResponse> 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<string> { "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<string>)["--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<string> { "Basic" }, MessageResponseCode.Success
);
}
}
}
return new GetAuthenticationCredentialsResponse(null, null, null, null, responseCode: MessageResponseCode.NotFound);
}
private static async Task<string?> 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;
}
}

18
ICredentialProvider.cs Normal file
View File

@@ -0,0 +1,18 @@
using System;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal interface ICredentialProvider
{
/// <summary>
/// Checks if implementation can provide credentials.
/// </summary>
/// <param name="uri">The of the target.</param>
Task<bool> CanProvideCredentialsAsync(Uri uri, CancellationToken cancel);
/// <summary>
/// Handle credential request.
/// </summary>
Task<GetAuthenticationCredentialsResponse> HandleRequestAsync(GetAuthenticationCredentialsRequest request, CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,12 @@
using System;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal class InitializeRequestHandler : RequestHandlerBase<InitializeRequest, InitializeResponse>
{
public override Task<InitializeResponse> HandleRequestAsync(InitializeRequest request, CancellationToken cancel)
{
return Task.FromResult(new InitializeResponse(MessageResponseCode.Success));
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NuGet.Protocol" Version="6.13.2" />
</ItemGroup>
</Project>

24
NugetSecretCredential.sln Normal file
View File

@@ -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

145
Program.cs Normal file
View File

@@ -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<int> Main(string[] args)
{
File.WriteAllText("/home/rv/temp/nsc.log", "Starting");
CancellationTokenSource tokenSource = new CancellationTokenSource();
List<ICredentialProvider> credentialProviders = new List<ICredentialProvider>
{
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<object>();
var endShutdownTaskSource = new TaskCompletionSource<object>();
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");
}
}
}

40
RequestHandlerBase.cs Normal file
View File

@@ -0,0 +1,40 @@
using System;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal abstract class RequestHandlerBase<TRequest, TResponse> : 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<TRequest>(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<TResponse> HandleRequestAsync(TRequest request, CancellationToken cancel);
protected virtual AutomaticProgressReporter? GetProgressReporter(IConnection connection, Message message, CancellationToken cancel)
{
return null;
}
}

View File

@@ -0,0 +1,28 @@
using System;
using System.Collections.Concurrent;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal class RequestHandlerCollection : ConcurrentDictionary<MessageMethod, IRequestHandler>, IRequestHandlers
{
public void Add(MessageMethod method, IRequestHandler handler)
{
TryAdd(method, handler);
}
public void AddOrUpdate(MessageMethod method, Func<IRequestHandler> addHandlerFunc, Func<IRequestHandler, IRequestHandler> 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 _);
}
}

View File

@@ -0,0 +1,15 @@
using System;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal class SetCredentialsRequestHandler : RequestHandlerBase<SetCredentialsRequest, SetCredentialsResponse>
{
private static readonly SetCredentialsResponse SuccessResponse = new SetCredentialsResponse(MessageResponseCode.Success);
public override Task<SetCredentialsResponse> HandleRequestAsync(SetCredentialsRequest request, CancellationToken cancel)
{
// There's currently no way to handle proxies, so nothing we can do here
return Task.FromResult(SuccessResponse);
}
}

View File

@@ -0,0 +1,14 @@
using System;
using NuGet.Protocol.Plugins;
namespace NugetSecretCredential;
internal class SetLogLevelRequestHandler : RequestHandlerBase<SetLogLevelRequest, SetLogLevelResponse>
{
private static readonly SetLogLevelResponse SuccessResponse = new SetLogLevelResponse(MessageResponseCode.Success);
public override Task<SetLogLevelResponse> HandleRequestAsync(SetLogLevelRequest request, CancellationToken cancel)
{
return Task.FromResult(SuccessResponse);
}
}