Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions sdk/identity/Azure.Identity/src/ConfigurableCredentialCache.cs
Original file line number Diff line number Diff line change
@@ -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<string, ConfigurableCredential>? s_cache;
private static ConcurrentDictionary<string, ConfigurableCredential> Cache =>
LazyInitializer.EnsureInitialized(ref s_cache, static () => new ConcurrentDictionary<string, ConfigurableCredential>())!;

public static ConfigurableCredential GetOrAdd(IConfigurationSection credentialSection, Func<ConfigurableCredential> factory)
{
string key = CreateKey(credentialSection);
return Cache.GetOrAdd(key, _ => factory());
}

/// <summary>
/// Creates a deterministic cache key from the content of an <see cref="IConfigurationSection"/>.
/// 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.
/// </summary>
private static string CreateKey(IConfigurationSection section)
{
string basePath = section.Path;
int prefixLength = basePath.Length > 0 ? basePath.Length + 1 : 0; // +1 for the ':' separator

IEnumerable<KeyValuePair<string, string?>> entries = section.AsEnumerable()
.Where(kvp => kvp.Value is not null)
.OrderBy(kvp => kvp.Key, StringComparer.Ordinal);

StringBuilder sb = new();
foreach (KeyValuePair<string, string?> 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
}
}
}
22 changes: 17 additions & 5 deletions sdk/identity/Azure.Identity/src/ConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -108,8 +109,12 @@ public static T WithAzureCredential<T>(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;
}
Expand Down Expand Up @@ -137,17 +142,24 @@ private static void AddDefaultScope(ClientSettings settings)

/// <summary>
/// Registers a credential factory to return a <see cref="TokenCredential"/> to use for the current <see cref="IClientBuilder"/>.
/// If the same credential configuration has already been registered, the existing credential instance is reused.
/// </summary>
/// <param name="clientBuilder">The <see cref="IClientBuilder"/> to add the credential to.</param>
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);
Comment thread
m-nash marked this conversation as resolved.
return new ConfigurableCredential(options);
});
});
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PackageReference Include="Azure.Security.KeyVault.Secrets" />
<PackageReference Include="Azure.Messaging.EventHubs" />
<PackageReference Include="Azure.Storage.Blobs" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Remove before shipping GA -->
<PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> values)
{
var configData = new Dictionary<string, string>();
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<string, string>
{
["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<string, string>
{
["TenantId"] = Unique()
});
var section2 = BuildCredentialSection("Client2:Credential", new Dictionary<string, string>
{
["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<string, string>());
var section2 = BuildCredentialSection("Other:Credential", new Dictionary<string, string>());

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<string, string>
{
["Zebra"] = nonce,
["Alpha"] = "a"
});
var section2 = BuildCredentialSection("B:Credential", new Dictionary<string, string>
{
["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<string, string>
{
["TenantId"] = nonce
});
var section2 = BuildCredentialSection("Client2:Credential", new Dictionary<string, string>
{
["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<string, string>
{
["Client1:Credential:TenantId"] = nonce,
["Client1:Credential:Nested:Value"] = "deep"
};
var configData2 = new Dictionary<string, string>
{
["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<string, string>
{
["CredentialSource"] = nonce,
["AdditionallyAllowedTenants:0"] = "tenant-a",
["AdditionallyAllowedTenants:1"] = "tenant-b",
});
var section2 = BuildCredentialSection("Client2:Credential", new Dictionary<string, string>
{
["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<string, string>
{
["CredentialSource"] = nonce,
["AdditionallyAllowedTenants:0"] = Unique(),
});
var section2 = BuildCredentialSection("Client2:Credential", new Dictionary<string, string>
{
["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<string, string>
{
["CredentialSource"] = nonce,
["AdditionallyAllowedTenants:0"] = "tenant-a",
});
var section2 = BuildCredentialSection("Client2:Credential", new Dictionary<string, string>
{
["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);
}
}
}
Loading
Loading