Created
May 24, 2026 08:11
-
-
Save waf/842334830f8b2c97d6b680efd11adf34 to your computer and use it in GitHub Desktop.
.NET 10 IOptions Configuration Source for Windows Credential Manager
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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