diff --git a/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs new file mode 100644 index 000000000000..17be89a6b679 --- /dev/null +++ b/sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#nullable enable + +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 System.Threading; +using Microsoft.Extensions.Configuration; + +namespace Azure.Identity +{ + [Experimental("SCME0002")] + internal static class ConfigurableCredentialCache + { + private static ConcurrentDictionary? s_cache; + private static ConcurrentDictionary Cache => + LazyInitializer.EnsureInitialized(ref s_cache, static () => new ConcurrentDictionary())!; + + public static ConfigurableCredential GetOrAdd(IConfigurationSection credentialSection, Func factory) + { + string key = CreateKey(credentialSection); + 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. + /// + 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() + .Where(kvp => kvp.Value is not null) + .OrderBy(kvp => kvp.Key, StringComparer.Ordinal); + + 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()); +#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..2e1f3a14fe87 100644 --- a/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs +++ b/sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using Azure.Core; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Azure.Identity @@ -108,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; } @@ -137,17 +142,24 @@ 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 => + { + 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"); + settings.CredentialProvider = ConfigurableCredentialCache.GetOrAdd(credentialSection, () => + { + 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..00ad3bea83e5 --- /dev/null +++ b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/ConfigurableCredentialCacheTests.cs @@ -0,0 +1,222 @@ +// 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; + +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); + } + + // 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() + { + string nonce = Unique(); + var values = new Dictionary + { + ["TenantId"] = nonce, + ["CredentialSource"] = "AzureCli" + }; + + var section1 = BuildCredentialSection("Client1:Credential", values); + var section2 = BuildCredentialSection("Client2:Credential", values); + + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); + + Assert.AreSame(cred1, cred2); + } + + [Test] + public void GetOrAdd_DifferentValues_ReturnsDifferentInstances() + { + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["TenantId"] = Unique() + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["TenantId"] = Unique() + }); + + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); + + Assert.AreNotSame(cred1, cred2); + } + + [Test] + public void GetOrAdd_EmptySections_ReturnsSameInstance() + { + var section1 = BuildCredentialSection("Client:Credential", new Dictionary()); + var section2 = BuildCredentialSection("Other:Credential", new Dictionary()); + + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); + + Assert.AreSame(cred1, cred2); + } + + [Test] + public void GetOrAdd_OrderIndependent_ReturnsSameInstance() + { + string nonce = Unique(); + var section1 = BuildCredentialSection("A:Credential", new Dictionary + { + ["Zebra"] = nonce, + ["Alpha"] = "a" + }); + var section2 = BuildCredentialSection("B:Credential", new Dictionary + { + ["Alpha"] = "a", + ["Zebra"] = nonce + }); + + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); + + Assert.AreSame(cred1, cred2); + } + + [Test] + public void GetOrAdd_SameValues_FactoryCalledOnce() + { + string nonce = Unique(); + int factoryCallCount = 0; + + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["TenantId"] = nonce + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["TenantId"] = nonce + }); + + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => + { + factoryCallCount++; + return new ConfigurableCredential(); + }); + + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => + { + factoryCallCount++; + return new ConfigurableCredential(); + }); + + Assert.AreSame(cred1, cred2); + Assert.AreEqual(1, factoryCallCount); + } + + [Test] + public void GetOrAdd_NestedValues_SameContentReturnsSameInstance() + { + string nonce = Unique(); + var configData1 = new Dictionary + { + ["Client1:Credential:TenantId"] = nonce, + ["Client1:Credential:Nested:Value"] = "deep" + }; + var configData2 = new Dictionary + { + ["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 = ConfigurableCredentialCache.GetOrAdd(config1.GetSection("Client1:Credential"), () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(config2.GetSection("Client2:Credential"), () => new ConfigurableCredential()); + + Assert.AreSame(cred1, cred2); + } + + [Test] + public void GetOrAdd_SameArrayValues_ReturnsSameInstance() + { + string nonce = Unique(); + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-b", + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-b", + }); + + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); + + Assert.AreSame(cred1, cred2); + } + + [Test] + public void GetOrAdd_DifferentArrayValues_ReturnsDifferentInstances() + { + string nonce = Unique(); + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = Unique(), + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = Unique(), + }); + + var cred1 = ConfigurableCredentialCache.GetOrAdd(section1, () => new ConfigurableCredential()); + var cred2 = ConfigurableCredentialCache.GetOrAdd(section2, () => new ConfigurableCredential()); + + Assert.AreNotSame(cred1, cred2); + } + + [Test] + public void GetOrAdd_DifferentArrayLength_ReturnsDifferentInstances() + { + string nonce = Unique(); + var section1 = BuildCredentialSection("Client1:Credential", new Dictionary + { + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = "tenant-a", + }); + var section2 = BuildCredentialSection("Client2:Credential", new Dictionary + { + ["CredentialSource"] = nonce, + ["AdditionallyAllowedTenants:0"] = "tenant-a", + ["AdditionallyAllowedTenants:1"] = "tenant-b", + }); + + 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 new file mode 100644 index 000000000000..3545900f8dbf --- /dev/null +++ b/sdk/identity/Azure.Identity/tests/ConfigurableCredentials/WithAzureCredentialTests.cs @@ -0,0 +1,556 @@ +// 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_SharesCredentialInstance() + { + 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.SameAs(client2.Settings.CredentialProvider)); + } + + [Test] + public void SeparateHosts_KeyedClients_SameConfig_SharesCredentialInstance() + { + 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.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)); + } + + [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)); + } + } +}