From 64a619ae00b39374a6a3a35197cc02d3d9ca944a Mon Sep 17 00:00:00 2001 From: Vladimir Zagray Date: Wed, 21 Oct 2026 16:33:32 +0300 Subject: [PATCH 1/2] Support nuget.config source credentials with env var expansion --- src/Tests/PackageFeedTests.cs | 50 ++++++++++++++++ src/Tests/SourceCredentialsTests.cs | 91 +++++++++++++++++++++++++++++ src/ndx/App.cs | 23 +++++--- src/ndx/PackageSources.cs | 65 ++++++++++++++------- src/ndx/SourceCredentials.cs | 49 ++++++++++++++++ 5 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 src/Tests/SourceCredentialsTests.cs create mode 100644 src/ndx/SourceCredentials.cs 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..08a39aa --- /dev/null +++ b/src/Tests/SourceCredentialsTests.cs @@ -0,0 +1,91 @@ +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 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..90df957 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,41 @@ 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; + + // 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 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; + } +} From f0dcec8f1a40e5a95503a62b2e5a6361ddf7fcda Mon Sep 17 00:00:00 2001 From: Vladimir Zagray Date: Fri, 23 Oct 2026 18:15:55 +0300 Subject: [PATCH 2/2] Support NuGetPackageSourceCredentials_{name} environment variables --- src/Tests/SourceCredentialsTests.cs | 72 +++++++++++++++++++++++++++++ src/ndx/PackageSources.cs | 24 ++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/Tests/SourceCredentialsTests.cs b/src/Tests/SourceCredentialsTests.cs index 08a39aa..932e5a8 100644 --- a/src/Tests/SourceCredentialsTests.cs +++ b/src/Tests/SourceCredentialsTests.cs @@ -47,6 +47,78 @@ public void Config_without_credentials_yields_none() 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() { diff --git a/src/ndx/PackageSources.cs b/src/ndx/PackageSources.cs index 90df957..82b2b8a 100644 --- a/src/ndx/PackageSources.cs +++ b/src/ndx/PackageSources.cs @@ -95,6 +95,12 @@ public static ResolvedSources ReadConfig(string configPath) 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)); @@ -107,6 +113,24 @@ public static ResolvedSources ReadConfig(string configPath) 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")