Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save waf/842334830f8b2c97d6b680efd11adf34 to your computer and use it in GitHub Desktop.

Select an option

Save waf/842334830f8b2c97d6b680efd11adf34 to your computer and use it in GitHub Desktop.
.NET 10 IOptions Configuration Source for Windows Credential Manager
using System.ComponentModel;
using System.Runtime.Versioning;
using Meziantou.Framework.Win32; // written against v2.0 of https://www.nuget.org/packages/Meziantou.Framework.Win32.CredentialManager
using Microsoft.Extensions.Configuration.EnvironmentVariables;
/// <summary>
/// Configuration provider that loads secrets from the Windows Credential Manager.
///
/// On non-Windows hosts <see cref="AddWindowsCredentialManager"/> is a no-op, so the build stays
/// cross-platform, and these non-Windows hosts can still load configuration via environment variables.
///
/// Add a secret using the following, where "MyApp:SomeOption:Key" is standard AppSettings/IOptions hierarchy syntax:
/// <code>
/// cmdkey /generic:MyApp:SomeOption:Key /user:_ /pass:your-secret-value
/// </code>
///
/// The prefix passed to <see cref="AddWindowsCredentialManager"/> is a Win32 enumeration filter —
/// Windows only returns credentials whose target name starts with it. In the example above, this would
/// be "MyApp:". This scopes the provider to the app's secrets and keeps unrelated credentials (RDP,
/// Outlook, etc.) out of the configuration even though the process has read access to them.
///
/// Inserts itself before environment variables, so a value set in Credential Manager can still be
/// overridden by environment variables if needed.
/// </summary>
public static class ConfigurationBuilderExtensions
{
/// <summary>
/// Registers the Windows Credential Manager configuration source. On non-Windows hosts this
/// returns the builder unchanged so the same call works in dev containers / CI on Linux.
/// </summary>
public static IConfigurationBuilder AddWindowsCredentialManager(this IConfigurationBuilder builder, string prefix)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(prefix);
// 5.1.2600 = Windows XP. Anything that can run .NET 10 is far past this — the explicit
// version is required to prevent analyzer warning CA1416 (Validate platform compatibility).
if (!OperatingSystem.IsWindowsVersionAtLeast(5, 1, 2600))
{
return builder;
}
var source = new WindowsCredentialManagerConfigurationSource(prefix);
// Add it before the unprefixed environment-variables source.
for (var i = 0; i < builder.Sources.Count; i++)
{
// The match must be Prefix:null because we actually have two EnvironmentVariablesConfigurationSource entries, one
// for a DOTNET_-prefixed env-var sourced (loads very early) and the general env-var source with Prefix:null.
if (builder.Sources[i] is EnvironmentVariablesConfigurationSource { Prefix: null })
{
builder.Sources.Insert(i, source);
return builder;
}
}
return builder.Add(source);
}
}
[SupportedOSPlatform("windows5.1.2600")]
internal sealed class WindowsCredentialManagerConfigurationSource(string prefix)
: ConfigurationProvider,
IConfigurationSource
{
// CredEnumerate's "no matches" condition surfaces as ERROR_NOT_FOUND from Win32.
// First-run / no-secrets-yet must not crash startup.
private const int ErrorNotFound = 1168;
public string Prefix { get; } = prefix;
IConfigurationProvider IConfigurationSource.Build(IConfigurationBuilder builder) => this;
public override void Load()
{
IReadOnlyList<Credential> matches;
try
{
// EnumerateCredentials' filter is a Win32 wildcard pattern passed straight to
// CredEnumerate, so unrelated credentials (RDP, Outlook, etc.) are filtered out by
// Windows before they ever reach this process.
matches = CredentialManager.EnumerateCredentials(Prefix + "*");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == ErrorNotFound)
{
matches = [];
}
var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var cred in matches)
{
// Double-check; theoretically Win32 already filtered by the wildcard, but guard against any edge case where a non-matching credential slips through (e.g. mis-set filter).
if (!cred.ApplicationName.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
data[cred.ApplicationName] = cred.Password;
}
Data = data;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment