From 2b3f7d21ee1c0d328cec580af16db9e33b773250 Mon Sep 17 00:00:00 2001 From: m-nash <64171366+m-nash@users.noreply.github.com> Date: Thu, 12 Feb 2026 18:01:43 -0800 Subject: [PATCH 1/3] Support credential instance reuse in WithAzureCredential DI path (#56231) --- .../src/ConfigurableCredentialCache.cs | 60 +++ .../src/ConfigurationExtensions.cs | 20 +- .../tests/Azure.Identity.Tests.csproj | 1 + .../ConfigurableCredentialCacheTests.cs | 219 +++++++++ .../WithAzureCredentialTests.cs | 452 ++++++++++++++++++ 5 files changed, 749 insertions(+), 3 deletions(-) create mode 100644 sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs create mode 100644 sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs create mode 100644 sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs diff --git a/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs new file mode 100644 index 000000000000..ffdeb28f2e63 --- /dev/null +++ b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Configuration; + +namespace Azure.Identity +{ + [Experimental("SCME0002")] + internal class ConfigurableCredentialCache + { + private readonly ConcurrentDictionary _cache = new(); + + public ConfigurableCredential GetOrAdd(string key, Func factory) + { + return _cache.GetOrAdd(key, _ => factory()); + } + + /// + /// Creates a deterministic cache key from the content of an . + /// Two sections at different paths but with identical values will produce the same key. + /// The key is a SHA256 hash to avoid leaking secrets that may be present in configuration values. + /// + internal static string CreateKey(IConfigurationSection section) + { + string basePath = section.Path; + int prefixLength = basePath.Length > 0 ? basePath.Length + 1 : 0; // +1 for the ':' separator + + StringBuilder sb = new(); + foreach (KeyValuePair kvp in section.AsEnumerable() + .Where(kvp => kvp.Value is not null) + .OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) + { + string relativeKey = kvp.Key.Length > prefixLength + ? kvp.Key.Substring(prefixLength) + : string.Empty; + + sb.Append(relativeKey).Append('=').Append(kvp.Value).Append(';'); + } + + byte[] inputBytes = Encoding.UTF8.GetBytes(sb.ToString()); +#if NETSTANDARD2_0 + using (SHA256 sha256 = SHA256.Create()) + { + byte[] hash = sha256.ComputeHash(inputBytes); + return BitConverter.ToString(hash).Replace("-", string.Empty); + } +#else + byte[] hash = SHA256.HashData(inputBytes); + return Convert.ToHexString(hash); +#endif + } + } +} diff --git a/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs b/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs index 8db7c7ae8c91..41499fa34fd4 100644 --- a/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs +++ b/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs @@ -5,8 +5,10 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using Azure.Core; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Azure.Identity @@ -17,6 +19,8 @@ namespace Azure.Identity [Experimental("SCME0002")] public static class ConfigurationExtensions { + private static readonly ConditionalWeakTable s_credentialCaches = new(); + /// /// Creates an instance of and sets its properties from the specified . /// @@ -137,17 +141,27 @@ private static void AddDefaultScope(ClientSettings settings) /// /// Registers a credential factory to return a to use for the current . + /// If the same credential configuration has already been registered, the existing credential instance is reused. /// /// The to add the credential to. public static IHostApplicationBuilder WithAzureCredential(this IClientBuilder clientBuilder) - => clientBuilder.PostConfigure(settings => + { + var cache = s_credentialCaches.GetValue(clientBuilder.Services, _ => new ConfigurableCredentialCache()); + + return clientBuilder.PostConfigure(settings => { AddDefaultScope(settings); settings.PostConfigure(config => { - DefaultAzureCredentialOptions options = new(settings.Credential, config.GetSection("Credential")); - settings.CredentialProvider = new ConfigurableCredential(options); + IConfigurationSection credentialSection = config.GetSection("Credential"); + string cacheKey = ConfigurableCredentialCache.CreateKey(credentialSection); + settings.CredentialProvider = cache.GetOrAdd(cacheKey, () => + { + DefaultAzureCredentialOptions options = new(settings.Credential, credentialSection); + return new ConfigurableCredential(options); + }); }); }); + } } } diff --git a/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj b/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj index 4b46803c6b3b..f0187accf132 100644 --- a/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj +++ b/sdk/identity/Azure.Identity/tests/Azure.Identity.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs new file mode 100644 index 000000000000..cd39a69a303c --- /dev/null +++ b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; + +namespace Azure.Identity.Tests.ConfigurableCredentials +{ + public class ConfigurableCredentialCacheTests + { + private static IConfigurationSection BuildCredentialSection(string sectionPath, Dictionary values) + { + var configData = new Dictionary(); + foreach (var kvp in values) + { + configData[$"{sectionPath}:{kvp.Key}"] = kvp.Value; + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configData) + .Build(); + + return configuration.GetSection(sectionPath); + } + + [Test] + public void CreateKey_SameValuesDifferentPaths_ProducesSameKey() + { + var values = new Dictionary + { + ["TenantId"] = "abc-123", + ["CredentialSource"] = "AzureCli" + }; + + var section1 = BuildCredentialSection("Client1:Credential", values); + var section2 = BuildCredentialSection("Client2:Credential", values); + + string key1 = ConfigurableCredentialCache.CreateKey(section1); + string key2 = ConfigurableCredentialCache.CreateKey(section2); + + Assert.AreEqual(key1, key2); + } + + [Test] + public void CreateKey_DifferentValues_ProducesDifferentKeys() + { + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["TenantId"] = "tenant-a" + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["TenantId"] = "tenant-b" + }); + + string key1 = ConfigurableCredentialCache.CreateKey(section1); + string key2 = ConfigurableCredentialCache.CreateKey(section2); + + Assert.AreNotEqual(key1, key2); + } + + [Test] + public void CreateKey_EmptySection_ProducesConsistentKey() + { + var section = BuildCredentialSection("Client:Credential", new Dictionary()); + + string key = ConfigurableCredentialCache.CreateKey(section); + + Assert.IsNotNull(key); + Assert.IsNotEmpty(key); + // Same empty section should produce the same hash + var section2 = BuildCredentialSection("Other:Credential", new Dictionary()); + Assert.AreEqual(key, ConfigurableCredentialCache.CreateKey(section2)); + } + + [Test] + public void CreateKey_OrderIndependent_ProducesSameKey() + { + // Build two sections with the same values but inserted in different order + var section1 = BuildCredentialSection("A:Credential", new Dictionary + { + ["Zebra"] = "z", + ["Alpha"] = "a" + }); + var section2 = BuildCredentialSection("B:Credential", new Dictionary + { + ["Alpha"] = "a", + ["Zebra"] = "z" + }); + + string key1 = ConfigurableCredentialCache.CreateKey(section1); + string key2 = ConfigurableCredentialCache.CreateKey(section2); + + Assert.AreEqual(key1, key2); + } + + [Test] + public void GetOrAdd_SameKey_ReturnsSameInstance() + { + var cache = new ConfigurableCredentialCache(); + int factoryCallCount = 0; + + var cred1 = cache.GetOrAdd("key1", () => + { + factoryCallCount++; + return new ConfigurableCredential(); + }); + + var cred2 = cache.GetOrAdd("key1", () => + { + factoryCallCount++; + return new ConfigurableCredential(); + }); + + Assert.AreSame(cred1, cred2); + Assert.AreEqual(1, factoryCallCount); + } + + [Test] + public void GetOrAdd_DifferentKeys_ReturnsDifferentInstances() + { + var cache = new ConfigurableCredentialCache(); + + var cred1 = cache.GetOrAdd("key1", () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd("key2", () => new ConfigurableCredential()); + + Assert.AreNotSame(cred1, cred2); + } + + [Test] + public void CreateKey_NestedValues_ProducesSameKeyRegardlessOfPath() + { + var configData1 = new Dictionary + { + ["Client1:Credential:TenantId"] = "abc", + ["Client1:Credential:Nested:Value"] = "deep" + }; + var configData2 = new Dictionary + { + ["Client2:Credential:TenantId"] = "abc", + ["Client2:Credential:Nested:Value"] = "deep" + }; + + var config1 = new ConfigurationBuilder().AddInMemoryCollection(configData1).Build(); + var config2 = new ConfigurationBuilder().AddInMemoryCollection(configData2).Build(); + + string key1 = ConfigurableCredentialCache.CreateKey(config1.GetSection("Client1:Credential")); + string key2 = ConfigurableCredentialCache.CreateKey(config2.GetSection("Client2:Credential")); + + Assert.AreEqual(key1, key2); + } + + [Test] + public void CreateKey_SameArrayValues_ProducesSameKey() + { + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["CredentialSource"] = "AzureCli", + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-b", + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["CredentialSource"] = "AzureCli", + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-b", + }); + + string key1 = ConfigurableCredentialCache.CreateKey(section1); + string key2 = ConfigurableCredentialCache.CreateKey(section2); + + Assert.AreEqual(key1, key2); + } + + [Test] + public void CreateKey_DifferentArrayValues_ProducesDifferentKeys() + { + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["CredentialSource"] = "AzureCli", + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-b", + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["CredentialSource"] = "AzureCli", + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-c", + }); + + string key1 = ConfigurableCredentialCache.CreateKey(section1); + string key2 = ConfigurableCredentialCache.CreateKey(section2); + + Assert.AreNotEqual(key1, key2); + } + + [Test] + public void CreateKey_DifferentArrayLength_ProducesDifferentKeys() + { + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["CredentialSource"] = "AzureCli", + ["AdditionallyAllowedTenants:0"] = "tenant-a", + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["CredentialSource"] = "AzureCli", + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-b", + }); + + string key1 = ConfigurableCredentialCache.CreateKey(section1); + string key2 = ConfigurableCredentialCache.CreateKey(section2); + + Assert.AreNotEqual(key1, key2); + } + } +} diff --git a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs new file mode 100644 index 000000000000..9bd61dece673 --- /dev/null +++ b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; + +namespace Azure.Identity.Tests.ConfigurableCredentials +{ + public class WithAzureCredentialTests + { + internal class SimpleTestClient + { + public SimpleTestClient(SimpleTestSettings settings) + { + Settings = settings; + } + + public SimpleTestSettings Settings { get; } + } + + internal class SimpleTestClient2 + { + public SimpleTestClient2(SimpleTestSettings2 settings) + { + Settings = settings; + } + + public SimpleTestSettings2 Settings { get; } + } + + internal class SimpleTestSettings : ClientSettings + { + public string Endpoint { get; set; } + + protected override void BindCore(IConfigurationSection section) + { + Endpoint = section["Endpoint"]; + } + } + + internal class SimpleTestSettings2 : ClientSettings + { + public string Endpoint { get; set; } + + protected override void BindCore(IConfigurationSection section) + { + Endpoint = section["Endpoint"]; + } + } + + [Test] + public void SameCredentialConfig_ReturnsSharedCredentialInstance() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "tenant-abc", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "tenant-abc", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client2.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void DifferentCredentialConfig_ReturnsDifferentCredentialInstances() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "tenant-abc", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "tenant-xyz", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client2.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void SameCredentialConfig_ApiKey_ReturnsSharedInstance() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "ApiKey", + ["Client1:Credential:Key"] = "my-shared-key", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "ApiKey", + ["Client2:Credential:Key"] = "my-shared-key", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void DifferentApiKeys_ReturnsDifferentInstances() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "ApiKey", + ["Client1:Credential:Key"] = "key-one", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "ApiKey", + ["Client2:Credential:Key"] = "key-two", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void KeyedClients_SameCredentialConfig_ReturnsSharedInstance() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "tenant-abc", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "tenant-abc", + }); + + builder.AddKeyedClient("key1", "Client1").WithAzureCredential(); + builder.AddKeyedClient("key2", "Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredKeyedService("key1"); + var client2 = host.Services.GetRequiredKeyedService("key2"); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client2.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void KeyedClients_DifferentCredentialConfig_ReturnsDifferentInstances() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "tenant-abc", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "tenant-xyz", + }); + + builder.AddKeyedClient("key1", "Client1").WithAzureCredential(); + builder.AddKeyedClient("key2", "Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredKeyedService("key1"); + var client2 = host.Services.GetRequiredKeyedService("key2"); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client2.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void MixedKeyedAndNonKeyed_SameCredentialConfig_ReturnsSharedInstance() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "tenant-abc", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "tenant-abc", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddKeyedClient("keyed", "Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("keyed"); + + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void SeparateHosts_SameCredentialConfig_DoNotShareCredentialInstances() + { + var sharedConfig = new Dictionary + { + ["MyClient:Endpoint"] = "https://test.example.com", + ["MyClient:Credential:CredentialSource"] = "AzureCli", + ["MyClient:Credential:TenantId"] = "tenant-abc", + }; + + HostApplicationBuilder builder1 = Host.CreateApplicationBuilder(); + builder1.Configuration.AddInMemoryCollection(sharedConfig); + builder1.AddClient("MyClient").WithAzureCredential(); + + HostApplicationBuilder builder2 = Host.CreateApplicationBuilder(); + builder2.Configuration.AddInMemoryCollection(sharedConfig); + builder2.AddClient("MyClient").WithAzureCredential(); + + IHost host1 = builder1.Build(); + IHost host2 = builder2.Build(); + var client1 = host1.Services.GetRequiredService(); + var client2 = host2.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client2.Settings.CredentialProvider, Is.Not.Null); + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void SeparateHosts_KeyedClients_DoNotShareCredentialInstances() + { + var sharedConfig = new Dictionary + { + ["MyClient:Endpoint"] = "https://test.example.com", + ["MyClient:Credential:CredentialSource"] = "ApiKey", + ["MyClient:Credential:Key"] = "shared-key", + }; + + HostApplicationBuilder builder1 = Host.CreateApplicationBuilder(); + builder1.Configuration.AddInMemoryCollection(sharedConfig); + builder1.AddKeyedClient("k1", "MyClient").WithAzureCredential(); + + HostApplicationBuilder builder2 = Host.CreateApplicationBuilder(); + builder2.Configuration.AddInMemoryCollection(sharedConfig); + builder2.AddKeyedClient("k1", "MyClient").WithAzureCredential(); + + IHost host1 = builder1.Build(); + IHost host2 = builder2.Build(); + var client1 = host1.Services.GetRequiredKeyedService("k1"); + var client2 = host2.Services.GetRequiredKeyedService("k1"); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void SameConfigSectionPath_SharesCredentialInstance() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["SharedSection:Endpoint"] = "https://test.example.com", + ["SharedSection:Credential:CredentialSource"] = "AzureCli", + ["SharedSection:Credential:TenantId"] = "tenant-abc", + }); + + // Two different client types pointing at the exact same config section + builder.AddClient("SharedSection").WithAzureCredential(); + builder.AddKeyedClient("keyed", "SharedSection").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("keyed"); + + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void SingleClient_CredentialProviderIsSet() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["MyClient:Endpoint"] = "https://test.example.com", + ["MyClient:Credential:CredentialSource"] = "AzureCli", + }); + + builder.AddClient("MyClient").WithAzureCredential(); + + IHost host = builder.Build(); + var client = host.Services.GetRequiredService(); + + Assert.That(client.Settings.CredentialProvider, Is.Not.Null); + } + + [Test] + public void CredentialSourceDifference_ReturnsDifferentInstances() + { + // Same TenantId but different CredentialSource should produce different credentials + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "tenant-abc", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzurePowerShell", + ["Client2:Credential:TenantId"] = "tenant-abc", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void ExtraCredentialProperty_ReturnsDifferentInstances() + { + // One section has an extra property the other doesn't — they should not share + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "extra-tenant", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void SameAllowedTenants_ReturnsSharedInstance() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:AdditionallyAllowedTenants:0"] = "tenant-a", + ["Client1:Credential:AdditionallyAllowedTenants:1"] = "tenant-b", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:AdditionallyAllowedTenants:0"] = "tenant-a", + ["Client2:Credential:AdditionallyAllowedTenants:1"] = "tenant-b", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void DifferentAllowedTenants_ReturnsDifferentInstances() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:AdditionallyAllowedTenants:0"] = "tenant-a", + ["Client1:Credential:AdditionallyAllowedTenants:1"] = "tenant-b", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:AdditionallyAllowedTenants:0"] = "tenant-a", + ["Client2:Credential:AdditionallyAllowedTenants:1"] = "tenant-c", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void DifferentAllowedTenantsLength_ReturnsDifferentInstances() + { + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:AdditionallyAllowedTenants:0"] = "tenant-a", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:AdditionallyAllowedTenants:0"] = "tenant-a", + ["Client2:Credential:AdditionallyAllowedTenants:1"] = "tenant-b", + }); + + builder.AddClient("Client1").WithAzureCredential(); + builder.AddClient("Client2").WithAzureCredential(); + + IHost host = builder.Build(); + var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredService(); + + Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + } + } +} From cb7c338eb0fedab02b4d3eb6740784e86a5bb44f Mon Sep 17 00:00:00 2001 From: m-nash <64171366+m-nash@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:05:43 -0800 Subject: [PATCH 2/3] Refactor credential cache: encapsulate key creation in GetOrAdd and simplify to StringBuilder --- .../src/ConfigurableCredentialCache.cs | 20 ++-- .../src/ConfigurationExtensions.cs | 3 +- .../ConfigurableCredentialCacheTests.cs | 108 ++++++++++-------- 3 files changed, 70 insertions(+), 61 deletions(-) diff --git a/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs index ffdeb28f2e63..3396896dc528 100644 --- a/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs +++ b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs @@ -17,8 +17,9 @@ internal class ConfigurableCredentialCache { private readonly ConcurrentDictionary _cache = new(); - public ConfigurableCredential GetOrAdd(string key, Func factory) + public ConfigurableCredential GetOrAdd(IConfigurationSection credentialSection, Func factory) { + string key = CreateKey(credentialSection); return _cache.GetOrAdd(key, _ => factory()); } @@ -27,21 +28,20 @@ public ConfigurableCredential GetOrAdd(string key, Func /// Two sections at different paths but with identical values will produce the same key. /// The key is a SHA256 hash to avoid leaking secrets that may be present in configuration values. /// - internal static string CreateKey(IConfigurationSection section) + private static string CreateKey(IConfigurationSection section) { string basePath = section.Path; int prefixLength = basePath.Length > 0 ? basePath.Length + 1 : 0; // +1 for the ':' separator - StringBuilder sb = new(); - foreach (KeyValuePair kvp in section.AsEnumerable() + IEnumerable> entries = section.AsEnumerable() .Where(kvp => kvp.Value is not null) - .OrderBy(kvp => kvp.Key, StringComparer.Ordinal)) - { - string relativeKey = kvp.Key.Length > prefixLength - ? kvp.Key.Substring(prefixLength) - : string.Empty; + .OrderBy(kvp => kvp.Key, StringComparer.Ordinal); - sb.Append(relativeKey).Append('=').Append(kvp.Value).Append(';'); + StringBuilder sb = new(); + foreach (KeyValuePair kvp in entries) + { + sb.Append(kvp.Key, prefixLength, kvp.Key.Length - prefixLength); + sb.Append('=').Append(kvp.Value).Append(';'); } byte[] inputBytes = Encoding.UTF8.GetBytes(sb.ToString()); diff --git a/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs b/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs index 41499fa34fd4..b9f83f797356 100644 --- a/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs +++ b/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs @@ -154,8 +154,7 @@ public static IHostApplicationBuilder WithAzureCredential(this IClientBuilder cl settings.PostConfigure(config => { IConfigurationSection credentialSection = config.GetSection("Credential"); - string cacheKey = ConfigurableCredentialCache.CreateKey(credentialSection); - settings.CredentialProvider = cache.GetOrAdd(cacheKey, () => + settings.CredentialProvider = cache.GetOrAdd(credentialSection, () => { DefaultAzureCredentialOptions options = new(settings.Credential, credentialSection); return new ConfigurableCredential(options); diff --git a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs index cd39a69a303c..49882067dbb5 100644 --- a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs +++ b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs @@ -25,8 +25,9 @@ private static IConfigurationSection BuildCredentialSection(string sectionPath, } [Test] - public void CreateKey_SameValuesDifferentPaths_ProducesSameKey() + public void GetOrAdd_SameValuesDifferentPaths_ReturnsSameInstance() { + var cache = new ConfigurableCredentialCache(); var values = new Dictionary { ["TenantId"] = "abc-123", @@ -36,15 +37,17 @@ public void CreateKey_SameValuesDifferentPaths_ProducesSameKey() var section1 = BuildCredentialSection("Client1:Credential", values); var section2 = BuildCredentialSection("Client2:Credential", values); - string key1 = ConfigurableCredentialCache.CreateKey(section1); - string key2 = ConfigurableCredentialCache.CreateKey(section2); + var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); - Assert.AreEqual(key1, key2); + Assert.AreSame(cred1, cred2); } [Test] - public void CreateKey_DifferentValues_ProducesDifferentKeys() + public void GetOrAdd_DifferentValues_ReturnsDifferentInstances() { + var cache = new ConfigurableCredentialCache(); + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { ["TenantId"] = "tenant-a" @@ -54,30 +57,31 @@ public void CreateKey_DifferentValues_ProducesDifferentKeys() ["TenantId"] = "tenant-b" }); - string key1 = ConfigurableCredentialCache.CreateKey(section1); - string key2 = ConfigurableCredentialCache.CreateKey(section2); + var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); - Assert.AreNotEqual(key1, key2); + Assert.AreNotSame(cred1, cred2); } [Test] - public void CreateKey_EmptySection_ProducesConsistentKey() + public void GetOrAdd_EmptySections_ReturnsSameInstance() { - var section = BuildCredentialSection("Client:Credential", new Dictionary()); - - string key = ConfigurableCredentialCache.CreateKey(section); + var cache = new ConfigurableCredentialCache(); - Assert.IsNotNull(key); - Assert.IsNotEmpty(key); - // Same empty section should produce the same hash + var section1 = BuildCredentialSection("Client:Credential", new Dictionary()); var section2 = BuildCredentialSection("Other:Credential", new Dictionary()); - Assert.AreEqual(key, ConfigurableCredentialCache.CreateKey(section2)); + + var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + + Assert.AreSame(cred1, cred2); } [Test] - public void CreateKey_OrderIndependent_ProducesSameKey() + public void GetOrAdd_OrderIndependent_ReturnsSameInstance() { - // Build two sections with the same values but inserted in different order + var cache = new ConfigurableCredentialCache(); + var section1 = BuildCredentialSection("A:Credential", new Dictionary { ["Zebra"] = "z", @@ -89,25 +93,34 @@ public void CreateKey_OrderIndependent_ProducesSameKey() ["Zebra"] = "z" }); - string key1 = ConfigurableCredentialCache.CreateKey(section1); - string key2 = ConfigurableCredentialCache.CreateKey(section2); + var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); - Assert.AreEqual(key1, key2); + Assert.AreSame(cred1, cred2); } [Test] - public void GetOrAdd_SameKey_ReturnsSameInstance() + public void GetOrAdd_SameSection_FactoryCalledOnce() { var cache = new ConfigurableCredentialCache(); int factoryCallCount = 0; - var cred1 = cache.GetOrAdd("key1", () => + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["TenantId"] = "abc" + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["TenantId"] = "abc" + }); + + var cred1 = cache.GetOrAdd(section1, () => { factoryCallCount++; return new ConfigurableCredential(); }); - var cred2 = cache.GetOrAdd("key1", () => + var cred2 = cache.GetOrAdd(section2, () => { factoryCallCount++; return new ConfigurableCredential(); @@ -118,19 +131,10 @@ public void GetOrAdd_SameKey_ReturnsSameInstance() } [Test] - public void GetOrAdd_DifferentKeys_ReturnsDifferentInstances() + public void GetOrAdd_NestedValues_SameContentReturnsSameInstance() { var cache = new ConfigurableCredentialCache(); - var cred1 = cache.GetOrAdd("key1", () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd("key2", () => new ConfigurableCredential()); - - Assert.AreNotSame(cred1, cred2); - } - - [Test] - public void CreateKey_NestedValues_ProducesSameKeyRegardlessOfPath() - { var configData1 = new Dictionary { ["Client1:Credential:TenantId"] = "abc", @@ -145,15 +149,17 @@ public void CreateKey_NestedValues_ProducesSameKeyRegardlessOfPath() var config1 = new ConfigurationBuilder().AddInMemoryCollection(configData1).Build(); var config2 = new ConfigurationBuilder().AddInMemoryCollection(configData2).Build(); - string key1 = ConfigurableCredentialCache.CreateKey(config1.GetSection("Client1:Credential")); - string key2 = ConfigurableCredentialCache.CreateKey(config2.GetSection("Client2:Credential")); + var cred1 = cache.GetOrAdd(config1.GetSection("Client1:Credential"), () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(config2.GetSection("Client2:Credential"), () => new ConfigurableCredential()); - Assert.AreEqual(key1, key2); + Assert.AreSame(cred1, cred2); } [Test] - public void CreateKey_SameArrayValues_ProducesSameKey() + public void GetOrAdd_SameArrayValues_ReturnsSameInstance() { + var cache = new ConfigurableCredentialCache(); + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { ["CredentialSource"] = "AzureCli", @@ -167,15 +173,17 @@ public void CreateKey_SameArrayValues_ProducesSameKey() ["AdditionallyAllowedTenants:1"] = "tenant-b", }); - string key1 = ConfigurableCredentialCache.CreateKey(section1); - string key2 = ConfigurableCredentialCache.CreateKey(section2); + var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); - Assert.AreEqual(key1, key2); + Assert.AreSame(cred1, cred2); } [Test] - public void CreateKey_DifferentArrayValues_ProducesDifferentKeys() + public void GetOrAdd_DifferentArrayValues_ReturnsDifferentInstances() { + var cache = new ConfigurableCredentialCache(); + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { ["CredentialSource"] = "AzureCli", @@ -189,15 +197,17 @@ public void CreateKey_DifferentArrayValues_ProducesDifferentKeys() ["AdditionallyAllowedTenants:1"] = "tenant-c", }); - string key1 = ConfigurableCredentialCache.CreateKey(section1); - string key2 = ConfigurableCredentialCache.CreateKey(section2); + var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); - Assert.AreNotEqual(key1, key2); + Assert.AreNotSame(cred1, cred2); } [Test] - public void CreateKey_DifferentArrayLength_ProducesDifferentKeys() + public void GetOrAdd_DifferentArrayLength_ReturnsDifferentInstances() { + var cache = new ConfigurableCredentialCache(); + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { ["CredentialSource"] = "AzureCli", @@ -210,10 +220,10 @@ public void CreateKey_DifferentArrayLength_ProducesDifferentKeys() ["AdditionallyAllowedTenants:1"] = "tenant-b", }); - string key1 = ConfigurableCredentialCache.CreateKey(section1); - string key2 = ConfigurableCredentialCache.CreateKey(section2); + var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); - Assert.AreNotEqual(key1, key2); + Assert.AreNotSame(cred1, cred2); } } } From 3fc13012fdd518806c2aa65e106128d48856c498 Mon Sep 17 00:00:00 2001 From: m-nash <64171366+m-nash@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:23:25 -0800 Subject: [PATCH 3/3] Share credential cache globally across DI and direct ClientSettings paths with lazy initialization --- .../src/ConfigurableCredentialCache.cs | 17 ++- .../src/ConfigurationExtensions.cs | 15 ++- .../ConfigurableCredentialCacheTests.cs | 101 ++++++++-------- .../WithAzureCredentialTests.cs | 112 +++++++++++++++++- 4 files changed, 173 insertions(+), 72 deletions(-) diff --git a/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs index 3396896dc528..17be89a6b679 100644 --- a/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs +++ b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +#nullable enable + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -8,19 +10,22 @@ using System.Linq; using System.Security.Cryptography; using System.Text; +using System.Threading; using Microsoft.Extensions.Configuration; namespace Azure.Identity { [Experimental("SCME0002")] - internal class ConfigurableCredentialCache + internal static class ConfigurableCredentialCache { - private readonly ConcurrentDictionary _cache = new(); + private static ConcurrentDictionary? s_cache; + private static ConcurrentDictionary Cache => + LazyInitializer.EnsureInitialized(ref s_cache, static () => new ConcurrentDictionary())!; - public ConfigurableCredential GetOrAdd(IConfigurationSection credentialSection, Func factory) + public static ConfigurableCredential GetOrAdd(IConfigurationSection credentialSection, Func factory) { string key = CreateKey(credentialSection); - return _cache.GetOrAdd(key, _ => factory()); + return Cache.GetOrAdd(key, _ => factory()); } /// @@ -33,12 +38,12 @@ private static string CreateKey(IConfigurationSection section) string basePath = section.Path; int prefixLength = basePath.Length > 0 ? basePath.Length + 1 : 0; // +1 for the ':' separator - IEnumerable> entries = section.AsEnumerable() + IEnumerable> entries = section.AsEnumerable() .Where(kvp => kvp.Value is not null) .OrderBy(kvp => kvp.Key, StringComparer.Ordinal); StringBuilder sb = new(); - foreach (KeyValuePair kvp in entries) + foreach (KeyValuePair kvp in entries) { sb.Append(kvp.Key, prefixLength, kvp.Key.Length - prefixLength); sb.Append('=').Append(kvp.Value).Append(';'); diff --git a/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs b/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs index b9f83f797356..2e1f3a14fe87 100644 --- a/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs +++ b/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs @@ -5,7 +5,6 @@ using System.ClientModel; using System.ClientModel.Primitives; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; using Azure.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -19,8 +18,6 @@ namespace Azure.Identity [Experimental("SCME0002")] public static class ConfigurationExtensions { - private static readonly ConditionalWeakTable s_credentialCaches = new(); - /// /// Creates an instance of and sets its properties from the specified . /// @@ -112,8 +109,12 @@ public static T WithAzureCredential(this T settings) settings.PostConfigure(config => { - DefaultAzureCredentialOptions options = new(settings.Credential, config.GetSection("Credential")); - settings.CredentialProvider = new ConfigurableCredential(options); + IConfigurationSection credentialSection = config.GetSection("Credential"); + settings.CredentialProvider = ConfigurableCredentialCache.GetOrAdd(credentialSection, () => + { + DefaultAzureCredentialOptions options = new(settings.Credential, credentialSection); + return new ConfigurableCredential(options); + }); }); return settings; } @@ -146,15 +147,13 @@ private static void AddDefaultScope(ClientSettings settings) /// The to add the credential to. public static IHostApplicationBuilder WithAzureCredential(this IClientBuilder clientBuilder) { - var cache = s_credentialCaches.GetValue(clientBuilder.Services, _ => new ConfigurableCredentialCache()); - return clientBuilder.PostConfigure(settings => { AddDefaultScope(settings); settings.PostConfigure(config => { IConfigurationSection credentialSection = config.GetSection("Credential"); - settings.CredentialProvider = cache.GetOrAdd(credentialSection, () => + settings.CredentialProvider = ConfigurableCredentialCache.GetOrAdd(credentialSection, () => { DefaultAzureCredentialOptions options = new(settings.Credential, credentialSection); return new ConfigurableCredential(options); diff --git a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs index 49882067dbb5..00ad3bea83e5 100644 --- a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs +++ b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System; using System.Collections.Generic; using Microsoft.Extensions.Configuration; using NUnit.Framework; @@ -24,21 +25,24 @@ private static IConfigurationSection BuildCredentialSection(string sectionPath, return configuration.GetSection(sectionPath); } + // Each test uses a unique nonce in its values to avoid interference from the global static cache. + private static string Unique() => Guid.NewGuid().ToString("N"); + [Test] public void GetOrAdd_SameValuesDifferentPaths_ReturnsSameInstance() { - var cache = new ConfigurableCredentialCache(); + string nonce = Unique(); var values = new Dictionary { - ["TenantId"] = "abc-123", + ["TenantId"] = nonce, ["CredentialSource"] = "AzureCli" }; var section1 = BuildCredentialSection("Client1:Credential", values); var section2 = BuildCredentialSection("Client2:Credential", values); - var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); Assert.AreSame(cred1, cred2); } @@ -46,19 +50,17 @@ public void GetOrAdd_SameValuesDifferentPaths_ReturnsSameInstance() [Test] public void GetOrAdd_DifferentValues_ReturnsDifferentInstances() { - var cache = new ConfigurableCredentialCache(); - var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { - ["TenantId"] = "tenant-a" + ["TenantId"] = Unique() }); var section2 = BuildCredentialSection("Client2:Credential", new Dictionary { - ["TenantId"] = "tenant-b" + ["TenantId"] = Unique() }); - var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); Assert.AreNotSame(cred1, cred2); } @@ -66,13 +68,11 @@ public void GetOrAdd_DifferentValues_ReturnsDifferentInstances() [Test] public void GetOrAdd_EmptySections_ReturnsSameInstance() { - var cache = new ConfigurableCredentialCache(); - var section1 = BuildCredentialSection("Client:Credential", new Dictionary()); var section2 = BuildCredentialSection("Other:Credential", new Dictionary()); - var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); Assert.AreSame(cred1, cred2); } @@ -80,47 +80,46 @@ public void GetOrAdd_EmptySections_ReturnsSameInstance() [Test] public void GetOrAdd_OrderIndependent_ReturnsSameInstance() { - var cache = new ConfigurableCredentialCache(); - + string nonce = Unique(); var section1 = BuildCredentialSection("A:Credential", new Dictionary { - ["Zebra"] = "z", + ["Zebra"] = nonce, ["Alpha"] = "a" }); var section2 = BuildCredentialSection("B:Credential", new Dictionary { ["Alpha"] = "a", - ["Zebra"] = "z" + ["Zebra"] = nonce }); - var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); Assert.AreSame(cred1, cred2); } [Test] - public void GetOrAdd_SameSection_FactoryCalledOnce() + public void GetOrAdd_SameValues_FactoryCalledOnce() { - var cache = new ConfigurableCredentialCache(); + string nonce = Unique(); int factoryCallCount = 0; var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { - ["TenantId"] = "abc" + ["TenantId"] = nonce }); var section2 = BuildCredentialSection("Client2:Credential", new Dictionary { - ["TenantId"] = "abc" + ["TenantId"] = nonce }); - var cred1 = cache.GetOrAdd(section1, () => + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => { factoryCallCount++; return new ConfigurableCredential(); }); - var cred2 = cache.GetOrAdd(section2, () => + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => { factoryCallCount++; return new ConfigurableCredential(); @@ -133,24 +132,23 @@ public void GetOrAdd_SameSection_FactoryCalledOnce() [Test] public void GetOrAdd_NestedValues_SameContentReturnsSameInstance() { - var cache = new ConfigurableCredentialCache(); - + string nonce = Unique(); var configData1 = new Dictionary { - ["Client1:Credential:TenantId"] = "abc", + ["Client1:Credential:TenantId"] = nonce, ["Client1:Credential:Nested:Value"] = "deep" }; var configData2 = new Dictionary { - ["Client2:Credential:TenantId"] = "abc", + ["Client2:Credential:TenantId"] = nonce, ["Client2:Credential:Nested:Value"] = "deep" }; var config1 = new ConfigurationBuilder().AddInMemoryCollection(configData1).Build(); var config2 = new ConfigurationBuilder().AddInMemoryCollection(configData2).Build(); - var cred1 = cache.GetOrAdd(config1.GetSection("Client1:Credential"), () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(config2.GetSection("Client2:Credential"), () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(config1.GetSection("Client1:Credential"), () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(config2.GetSection("Client2:Credential"), () => new ConfigurableCredential()); Assert.AreSame(cred1, cred2); } @@ -158,23 +156,22 @@ public void GetOrAdd_NestedValues_SameContentReturnsSameInstance() [Test] public void GetOrAdd_SameArrayValues_ReturnsSameInstance() { - var cache = new ConfigurableCredentialCache(); - + string nonce = Unique(); var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { - ["CredentialSource"] = "AzureCli", + ["CredentialSource"] = nonce, ["AdditionallyAllowedTenants:0"] = "tenant-a", ["AdditionallyAllowedTenants:1"] = "tenant-b", }); var section2 = BuildCredentialSection("Client2:Credential", new Dictionary { - ["CredentialSource"] = "AzureCli", + ["CredentialSource"] = nonce, ["AdditionallyAllowedTenants:0"] = "tenant-a", ["AdditionallyAllowedTenants:1"] = "tenant-b", }); - var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); Assert.AreSame(cred1, cred2); } @@ -182,23 +179,20 @@ public void GetOrAdd_SameArrayValues_ReturnsSameInstance() [Test] public void GetOrAdd_DifferentArrayValues_ReturnsDifferentInstances() { - var cache = new ConfigurableCredentialCache(); - + string nonce = Unique(); var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { - ["CredentialSource"] = "AzureCli", - ["AdditionallyAllowedTenants:0"] = "tenant-a", - ["AdditionallyAllowedTenants:1"] = "tenant-b", + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = Unique(), }); var section2 = BuildCredentialSection("Client2:Credential", new Dictionary { - ["CredentialSource"] = "AzureCli", - ["AdditionallyAllowedTenants:0"] = "tenant-a", - ["AdditionallyAllowedTenants:1"] = "tenant-c", + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = Unique(), }); - var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); Assert.AreNotSame(cred1, cred2); } @@ -206,22 +200,21 @@ public void GetOrAdd_DifferentArrayValues_ReturnsDifferentInstances() [Test] public void GetOrAdd_DifferentArrayLength_ReturnsDifferentInstances() { - var cache = new ConfigurableCredentialCache(); - + string nonce = Unique(); var section1 = BuildCredentialSection("Client1:Credential", new Dictionary { - ["CredentialSource"] = "AzureCli", + ["CredentialSource"] = nonce, ["AdditionallyAllowedTenants:0"] = "tenant-a", }); var section2 = BuildCredentialSection("Client2:Credential", new Dictionary { - ["CredentialSource"] = "AzureCli", + ["CredentialSource"] = nonce, ["AdditionallyAllowedTenants:0"] = "tenant-a", ["AdditionallyAllowedTenants:1"] = "tenant-b", }); - var cred1 = cache.GetOrAdd(section1, () => new ConfigurableCredential()); - var cred2 = cache.GetOrAdd(section2, () => new ConfigurableCredential()); + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); Assert.AreNotSame(cred1, cred2); } diff --git a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs index 9bd61dece673..3545900f8dbf 100644 --- a/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs +++ b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs @@ -230,7 +230,7 @@ public void MixedKeyedAndNonKeyed_SameCredentialConfig_ReturnsSharedInstance() } [Test] - public void SeparateHosts_SameCredentialConfig_DoNotShareCredentialInstances() + public void SeparateHosts_SameCredentialConfig_SharesCredentialInstance() { var sharedConfig = new Dictionary { @@ -254,11 +254,11 @@ public void SeparateHosts_SameCredentialConfig_DoNotShareCredentialInstances() Assert.That(client1.Settings.CredentialProvider, Is.Not.Null); Assert.That(client2.Settings.CredentialProvider, Is.Not.Null); - Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); } [Test] - public void SeparateHosts_KeyedClients_DoNotShareCredentialInstances() + public void SeparateHosts_KeyedClients_SameConfig_SharesCredentialInstance() { var sharedConfig = new Dictionary { @@ -280,7 +280,7 @@ public void SeparateHosts_KeyedClients_DoNotShareCredentialInstances() var client1 = host1.Services.GetRequiredKeyedService("k1"); var client2 = host2.Services.GetRequiredKeyedService("k1"); - Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); + Assert.That(client1.Settings.CredentialProvider, Is.SameAs(client2.Settings.CredentialProvider)); } [Test] @@ -448,5 +448,109 @@ public void DifferentAllowedTenantsLength_ReturnsDifferentInstances() Assert.That(client1.Settings.CredentialProvider, Is.Not.SameAs(client2.Settings.CredentialProvider)); } + + [Test] + public void DirectAndDI_SameCredentialConfig_SharesCredentialInstance() + { + // Create a credential via the direct ClientSettings path + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["DirectClient:Endpoint"] = "https://direct.example.com", + ["DirectClient:Credential:CredentialSource"] = "AzureCli", + ["DirectClient:Credential:TenantId"] = "cross-path-tenant", + }) + .Build(); + var directSettings = config.GetAzureClientSettings("DirectClient"); + + // Create a credential via the DI path with the same credential values + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["DIClient:Endpoint"] = "https://di.example.com", + ["DIClient:Credential:CredentialSource"] = "AzureCli", + ["DIClient:Credential:TenantId"] = "cross-path-tenant", + }); + builder.AddClient("DIClient").WithAzureCredential(); + + IHost host = builder.Build(); + var diClient = host.Services.GetRequiredService(); + + Assert.That(directSettings.CredentialProvider, Is.Not.Null); + Assert.That(diClient.Settings.CredentialProvider, Is.Not.Null); + Assert.That(directSettings.CredentialProvider, Is.SameAs(diClient.Settings.CredentialProvider)); + } + + [Test] + public void DirectAndDI_DifferentCredentialConfig_ReturnsDifferentInstances() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["DirectClient:Endpoint"] = "https://direct.example.com", + ["DirectClient:Credential:CredentialSource"] = "AzureCli", + ["DirectClient:Credential:TenantId"] = "direct-tenant", + }) + .Build(); + var directSettings = config.GetAzureClientSettings("DirectClient"); + + HostApplicationBuilder builder = Host.CreateApplicationBuilder(); + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["DIClient:Endpoint"] = "https://di.example.com", + ["DIClient:Credential:CredentialSource"] = "AzureCli", + ["DIClient:Credential:TenantId"] = "di-tenant", + }); + builder.AddClient("DIClient").WithAzureCredential(); + + IHost host = builder.Build(); + var diClient = host.Services.GetRequiredService(); + + Assert.That(directSettings.CredentialProvider, Is.Not.SameAs(diClient.Settings.CredentialProvider)); + } + + [Test] + public void DirectPath_SameCredentialConfig_SharesCredentialInstance() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "direct-shared-tenant", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "direct-shared-tenant", + }) + .Build(); + + var settings1 = config.GetAzureClientSettings("Client1"); + var settings2 = config.GetAzureClientSettings("Client2"); + + Assert.That(settings1.CredentialProvider, Is.Not.Null); + Assert.That(settings2.CredentialProvider, Is.Not.Null); + Assert.That(settings1.CredentialProvider, Is.SameAs(settings2.CredentialProvider)); + } + + [Test] + public void DirectPath_DifferentCredentialConfig_ReturnsDifferentInstances() + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Client1:Endpoint"] = "https://one.example.com", + ["Client1:Credential:CredentialSource"] = "AzureCli", + ["Client1:Credential:TenantId"] = "direct-tenant-a", + ["Client2:Endpoint"] = "https://two.example.com", + ["Client2:Credential:CredentialSource"] = "AzureCli", + ["Client2:Credential:TenantId"] = "direct-tenant-b", + }) + .Build(); + + var settings1 = config.GetAzureClientSettings("Client1"); + var settings2 = config.GetAzureClientSettings("Client2"); + + Assert.That(settings1.CredentialProvider, Is.Not.SameAs(settings2.CredentialProvider)); + } } }