diff --git a/src/Tests/PackageFeedTests.cs b/src/Tests/PackageFeedTests.cs
index d90bb46..46cdd12 100644
--- a/src/Tests/PackageFeedTests.cs
+++ b/src/Tests/PackageFeedTests.cs
@@ -280,6 +280,54 @@ public async Task App_writes_download_progress_to_host_Progress()
Assert.Contains(" B", ui, StringComparison.Ordinal);
}
+ [Fact]
+ public async Task Config_credentials_authenticate_feed_requests_with_source_override()
+ {
+ using var packages = new TempNupkgs();
+ var handler = MapFeed(packages);
+ var config = Path.Combine(packages.Root, "auth.config");
+ File.WriteAllText(config, $"""
+
+
+
+
+
+
+
+
+
+
+
+ """);
+ Environment.SetEnvironmentVariable("NDX_TEST_FEED_PAT", "s3cret");
+ using var error = new StringWriter();
+ var host = new NdxHost
+ {
+ HttpHandler = handler,
+ Error = error,
+ Out = new StringWriter(),
+ StoreDirectory = NewStore(),
+ ProcessRunner = new RecordingProcessRunner { ExitCode = 0 },
+ WorkingDirectory = packages.Root,
+ };
+
+ try
+ {
+ var code = await App.RunAsync(
+ [PackageId + "@" + VersionText, "--yes", "--source", Source, "--configfile", config], host);
+
+ Assert.Equal(0, code);
+ Assert.Equal("", error.ToString());
+ var expected = "Basic " + Convert.ToBase64String("user:s3cret"u8.ToArray());
+ Assert.Equal(expected, handler.Authorizations[Source]);
+ Assert.Equal(expected, handler.Authorizations[NupkgUrl(PackageId)]);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("NDX_TEST_FEED_PAT", null);
+ }
+ }
+
[Fact]
public async Task Http_download_succeeds_when_catalog_size_lookup_fails()
{
@@ -458,11 +506,13 @@ sealed class RecordingHandler : HttpMessageHandler
public Dictionary> Override { get; } = new(StringComparer.Ordinal);
public HashSet OmitContentLength { get; } = new(StringComparer.Ordinal);
public List Hits { get; } = [];
+ public Dictionary Authorizations { get; } = new(StringComparer.Ordinal);
protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri?.ToString() ?? "";
Hits.Add(url);
+ Authorizations[url] = request.Headers.Authorization?.ToString();
if (Override.TryGetValue(url, out var factory))
{
diff --git a/src/Tests/SourceCredentialsTests.cs b/src/Tests/SourceCredentialsTests.cs
new file mode 100644
index 0000000..932e5a8
--- /dev/null
+++ b/src/Tests/SourceCredentialsTests.cs
@@ -0,0 +1,163 @@
+using System.Net;
+using ndx;
+
+namespace Tests;
+
+public class SourceCredentialsTests
+{
+ [Fact]
+ public void Config_credentials_are_matched_to_sources_by_key()
+ {
+ var config = WriteConfig("""
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """);
+
+ var credentials = PackageSources.ReadConfig(config).Credentials;
+
+ var credential = Assert.Single(credentials);
+ Assert.Equal(new SourceCredential("https://pkgs.test/feed/index.json", "me", "pat"), credential);
+ }
+
+ [Fact]
+ public void Config_without_credentials_yields_none()
+ {
+ var config = WriteConfig("""
+
+
+
+
+
+ """);
+
+ Assert.Empty(PackageSources.ReadConfig(config).Credentials);
+ }
+
+ [Fact]
+ public void Environment_credentials_take_precedence_over_config()
+ {
+ var suffix = Guid.NewGuid().ToString("n");
+ var config = WriteConfig($"""
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """);
+
+ Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Feed{suffix}", "Username=env;Password=env-pat");
+ Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Other{suffix}", "Username=other;Password=other-pat;ValidAuthenticationTypes=basic");
+ Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Invalid{suffix}", "Password=pat");
+ try
+ {
+ var credentials = PackageSources.ReadConfig(config).Credentials;
+
+ Assert.Equal(
+ [
+ new SourceCredential("https://pkgs.test/feed/index.json", "env", "env-pat"),
+ new SourceCredential("https://other.test/index.json", "other", "other-pat"),
+ ],
+ credentials);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Feed{suffix}", null);
+ Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Other{suffix}", null);
+ Environment.SetEnvironmentVariable($"NuGetPackageSourceCredentials_Invalid{suffix}", null);
+ }
+ }
+
+ [Fact]
+ public void Config_credentials_expand_environment_variables()
+ {
+ var variable = "NDX_TEST_PAT_" + Guid.NewGuid().ToString("n");
+ var config = WriteConfig($"""
+
+
+
+
+
+
+
+
+
+
+
+ """);
+
+ Environment.SetEnvironmentVariable(variable, "secret");
+ try
+ {
+ var credential = Assert.Single(PackageSources.ReadConfig(config).Credentials);
+ Assert.Equal("secret", credential.Password);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(variable, null);
+ }
+ }
+
+ [Fact]
+ public async Task Credentials_are_sent_only_to_the_source_host()
+ {
+ var inner = new CapturingHandler();
+ using var http = new HttpClient(new SourceCredentialsHandler(
+ [new SourceCredential("https://pkgs.test/org/_packaging/feed/nuget/v3/index.json", "me", "pat")])
+ {
+ InnerHandler = inner,
+ });
+
+ await http.GetAsync("https://pkgs.test/org/_packaging/feed/nuget/v3/flat2/tool/index.json");
+ await http.GetAsync("https://api.nuget.org/v3/index.json");
+ await http.GetAsync("http://pkgs.test/org/_packaging/feed/nuget/v3/index.json");
+
+ Assert.Equal(
+ "Basic " + Convert.ToBase64String("me:pat"u8.ToArray()),
+ inner.Authorizations["https://pkgs.test/org/_packaging/feed/nuget/v3/flat2/tool/index.json"]);
+ Assert.Null(inner.Authorizations["https://api.nuget.org/v3/index.json"]);
+ Assert.Null(inner.Authorizations["http://pkgs.test/org/_packaging/feed/nuget/v3/index.json"]);
+ }
+
+ static string WriteConfig(string xml)
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "ndx-credentials", Guid.NewGuid().ToString("n"));
+ Directory.CreateDirectory(dir);
+
+ var path = Path.Combine(dir, "nuget.config");
+ File.WriteAllText(path, xml);
+ return path;
+ }
+
+ sealed class CapturingHandler : HttpMessageHandler
+ {
+ public Dictionary Authorizations { get; } = new(StringComparer.Ordinal);
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Authorizations[request.RequestUri!.ToString()] = request.Headers.Authorization?.ToString();
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request });
+ }
+ }
+}
diff --git a/src/ndx/App.cs b/src/ndx/App.cs
index 813ef33..7cce929 100644
--- a/src/ndx/App.cs
+++ b/src/ndx/App.cs
@@ -92,13 +92,13 @@ public static async Task RunAsync(string[] args, NdxHost? host = null, Canc
try
{
- using var http = CreateHttpClient(host);
+ var (sources, credentials) = PackageSources.Resolve(invocation, host.WorkingDirectory);
+ using var http = CreateHttpClient(host, credentials);
if (invocation.Update)
return await SelfUpdate.RunAsync(invocation, host, http, cancellationToken).ConfigureAwait(false);
var log = IsDetailed(invocation.Verbosity) ? host.Out : null;
- var sources = PackageSources.Resolve(invocation, host.WorkingDirectory);
var progress = host.Progress ?? (host.ShowProgress ? host.Error : null);
var feed = new PackageFeed(http, invocation.IgnoreFailedSources, log, progress);
var muxer = host.DotnetMuxer ?? DotnetMuxer.Resolve();
@@ -134,13 +134,18 @@ public static async Task RunAsync(string[] args, NdxHost? host = null, Canc
}
}
- static HttpClient CreateHttpClient(NdxHost host)
- => host.HttpHandler is { } handler
- ? new HttpClient(handler, disposeHandler: false)
- : new HttpClient(new SocketsHttpHandler
- {
- AutomaticDecompression = System.Net.DecompressionMethods.All,
- });
+ static HttpClient CreateHttpClient(NdxHost host, IReadOnlyList credentials)
+ {
+ var handler = host.HttpHandler ?? new SocketsHttpHandler
+ {
+ AutomaticDecompression = System.Net.DecompressionMethods.All,
+ };
+
+ if (credentials.Count > 0)
+ handler = new SourceCredentialsHandler(credentials) { InnerHandler = handler };
+
+ return new HttpClient(handler, disposeHandler: host.HttpHandler is null);
+ }
static bool IsDetailed(string? verbosity)
=> verbosity?.ToLowerInvariant() is "detailed" or "diagnostic" or "d" or "diag";
diff --git a/src/ndx/PackageSources.cs b/src/ndx/PackageSources.cs
index a7e30ce..82b2b8a 100644
--- a/src/ndx/PackageSources.cs
+++ b/src/ndx/PackageSources.cs
@@ -1,31 +1,31 @@
+using System.Xml;
using System.Xml.Linq;
namespace ndx;
+public sealed record ResolvedSources(IReadOnlyList Sources, IReadOnlyList Credentials);
+
///
-/// Resolves NuGet sources from --source / --add-source / --configfile / nuget.config.
+/// Resolves NuGet sources and credentials from --source / --add-source / --configfile / nuget.config.
///
public static class PackageSources
{
public const string NugetOrg = "https://api.nuget.org/v3/index.json";
- public static IReadOnlyList Resolve(Invocation invocation, string workingDirectory)
+ public static ResolvedSources Resolve(Invocation invocation, string workingDirectory)
{
- var sources = new List();
+ var configPath = invocation.ConfigFile is { } explicitConfig
+ ? Path.GetFullPath(explicitConfig, workingDirectory)
+ : FindNuGetConfig(workingDirectory);
- if (invocation.Sources.Count > 0)
- {
- sources.AddRange(invocation.Sources.Select(s => Normalize(s, workingDirectory)));
- }
- else
- {
- var configPath = invocation.ConfigFile is { } explicitConfig
- ? Path.GetFullPath(explicitConfig, workingDirectory)
- : FindNuGetConfig(workingDirectory);
+ // even if --source overrides config sources, credentials still apply to matching source URLs
+ var config = configPath is not null && File.Exists(configPath)
+ ? ReadConfig(configPath)
+ : new ResolvedSources([], []);
- if (configPath is not null && File.Exists(configPath))
- sources.AddRange(ReadConfigSources(configPath));
- }
+ var sources = invocation.Sources.Count > 0
+ ? invocation.Sources.Select(s => Normalize(s, workingDirectory)).ToList()
+ : config.Sources.ToList();
foreach (var extra in invocation.AddSources)
{
@@ -37,7 +37,7 @@ public static IReadOnlyList Resolve(Invocation invocation, string workin
if (sources.Count == 0)
sources.Add(NugetOrg);
- return sources;
+ return new ResolvedSources(sources, config.Credentials);
}
public static string? FindNuGetConfig(string startDirectory)
@@ -59,12 +59,12 @@ public static IReadOnlyList Resolve(Invocation invocation, string workin
return null;
}
- public static IReadOnlyList ReadConfigSources(string configPath)
+ public static ResolvedSources ReadConfig(string configPath)
{
var document = XDocument.Load(configPath);
var root = document.Root;
if (root is null)
- return [];
+ return new ResolvedSources([], []);
var disabled = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var item in root.Element("disabledPackageSources")?.Elements("add") ?? [])
@@ -79,18 +79,65 @@ public static IReadOnlyList ReadConfigSources(string configPath)
}
var sources = new List();
+ var credentials = new List();
+ var credentialEntries = root.Element("packageSourceCredentials")?.Elements().ToList() ?? [];
var configDirectory = Path.GetDirectoryName(configPath) ?? Directory.GetCurrentDirectory();
foreach (var item in root.Element("packageSources")?.Elements("add") ?? [])
{
var key = (string?)item.Attribute("key");
var value = (string?)item.Attribute("value");
- if (value is null || (key is not null && disabled.Contains(key)))
+ if (value is null)
continue;
- sources.Add(Normalize(value, configDirectory));
+ if (key is null || !disabled.Contains(key))
+ sources.Add(Normalize(value, configDirectory));
+
+ if (key is null)
+ continue;
+
+ if (ReadEnvironmentCredential(key, value) is { } environmentCredential)
+ {
+ credentials.Add(environmentCredential);
+ continue;
+ }
+
+ // nuget stores source keys as XML names, e.g. "My Feed" becomes My_x0020_Feed.
+ var entry = credentialEntries.FirstOrDefault(e =>
+ string.Equals(XmlConvert.DecodeName(e.Name.LocalName), key, StringComparison.OrdinalIgnoreCase));
+ var username = ReadSetting(entry, "Username");
+ var password = ReadSetting(entry, "ClearTextPassword");
+ if (username is not null && password is not null)
+ credentials.Add(new SourceCredential(value, username, password));
}
- return sources;
+ return new ResolvedSources(sources, credentials);
+ }
+
+ static SourceCredential? ReadEnvironmentCredential(string key, string source)
+ {
+ // same as nuget: Username={user};Password={password}[;ValidAuthenticationTypes={types}]
+ var value = Environment.GetEnvironmentVariable("NuGetPackageSourceCredentials_" + key);
+ if (value is null || !value.StartsWith("Username=", StringComparison.OrdinalIgnoreCase))
+ return null;
+
+ var separator = value.IndexOf(";Password=", StringComparison.OrdinalIgnoreCase);
+ if (separator < 0)
+ return null;
+
+ var username = value["Username=".Length..separator];
+ var password = value[(separator + ";Password=".Length)..];
+ var types = password.IndexOf(";ValidAuthenticationTypes=", StringComparison.OrdinalIgnoreCase);
+
+ return new SourceCredential(source, username, types < 0 ? password : password[..types]);
+ }
+
+ static string? ReadSetting(XElement? entry, string name)
+ {
+ var value = (string?)entry?.Elements("add")
+ .FirstOrDefault(e => string.Equals((string?)e.Attribute("key"), name, StringComparison.OrdinalIgnoreCase))
+ ?.Attribute("value");
+
+ return value is null ? null : Environment.ExpandEnvironmentVariables(value);
}
static string Normalize(string source, string baseDirectory)
diff --git a/src/ndx/SourceCredentials.cs b/src/ndx/SourceCredentials.cs
new file mode 100644
index 0000000..e920fb5
--- /dev/null
+++ b/src/ndx/SourceCredentials.cs
@@ -0,0 +1,49 @@
+using System.Net.Http.Headers;
+using System.Text;
+
+namespace ndx;
+
+public sealed record SourceCredential(string Source, string Username, string Password);
+
+///
+/// Adds basic authentication from nuget.config packageSourceCredentials.
+///
+public sealed class SourceCredentialsHandler(IEnumerable credentials) : DelegatingHandler
+{
+ readonly IReadOnlyList<(Uri source, AuthenticationHeaderValue header)> credentials = BuildHeaders(credentials);
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (request.Headers.Authorization is null && request.RequestUri is { } target)
+ {
+ foreach (var (source, header) in credentials)
+ {
+ if (Uri.Compare(source, target, UriComponents.SchemeAndServer, UriFormat.Unescaped, StringComparison.OrdinalIgnoreCase) == 0)
+ {
+ request.Headers.Authorization = header;
+ break;
+ }
+ }
+ }
+
+ return base.SendAsync(request, cancellationToken);
+ }
+
+ static List<(Uri Source, AuthenticationHeaderValue Header)> BuildHeaders(IEnumerable credentials)
+ {
+ var headers = new List<(Uri Source, AuthenticationHeaderValue Header)>();
+ foreach (var credential in credentials)
+ {
+ if (!Uri.TryCreate(credential.Source, UriKind.Absolute, out var source) ||
+ (source.Scheme != Uri.UriSchemeHttp && source.Scheme != Uri.UriSchemeHttps))
+ {
+ continue;
+ }
+
+ var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{credential.Username}:{credential.Password}"));
+ headers.Add((source, new AuthenticationHeaderValue("Basic", token)));
+ }
+
+ return headers;
+ }
+}