Skip to content

Commit dc242d6

Browse files
gladjohnCopilot
andauthored
Add PartitionAppTokenCacheByAudience to partition the app token cache by resource (#3979)
Apps that acquire app tokens (client credentials) for many resources using the same client and tenant accumulate all tokens in a single {clientId}_{tenantId} cache partition, making AcquireTokenForClient cache lookups O(n) in the number of resources. Add a MicrosoftIdentityOptions.PartitionAppTokenCacheByAudience opt-in (config bindable, flows into MergedOptions like UseFastUnboundedCache). When enabled, TokenAcquisition derives the resource from the requested /.default scope and calls MSAL's WithCachePartitionKey("resource", resource), which partitions both MSAL's internal cache and the serialized in-memory cache blob per resource, keeping reads O(1). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2d02b5ca-22b5-4b48-840a-de79786b3f9d
1 parent da9e825 commit dc242d6

6 files changed

Lines changed: 114 additions & 0 deletions

File tree

‎src/Microsoft.Identity.Web.TokenAcquisition/MergedOptions.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ public ConfidentialClientApplicationOptions ConfidentialClientApplicationOptions
8686
internal static void UpdateMergedOptionsFromMicrosoftIdentityOptions(MicrosoftIdentityOptions microsoftIdentityOptions, MergedOptions mergedOptions)
8787
{
8888
mergedOptions.UseFastUnboundedCache |= microsoftIdentityOptions.UseFastUnboundedCache;
89+
mergedOptions.PartitionAppTokenCacheByAudience |= microsoftIdentityOptions.PartitionAppTokenCacheByAudience;
8990

9091
#if NET5_0_OR_GREATER
9192
mergedOptions.MapInboundClaims = microsoftIdentityOptions.MapInboundClaims;

‎src/Microsoft.Identity.Web.TokenAcquisition/MicrosoftIdentityOptions.cs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,24 @@ public class MicrosoftIdentityOptions : OpenIdConnectOptions
105105
///
106106
public bool UseFastUnboundedCache { get; set; }
107107

108+
///
109+
/// When set to , the application token cache (client credentials,
110+
/// i.e. AcquireTokenForClient) is partitioned by the audience (resource) of the
111+
/// requested token.
112+
///
113+
/// Applications that acquire app tokens for many resources using the same client and tenant
114+
/// otherwise accumulate all of those tokens in a single cache partition, which makes cache
115+
/// lookups O(n) in the number of resources. Enabling this adds the resource (derived from the
116+
/// requested <resource>/.default scope) as a non-protocol-affecting cache key
117+
/// component, so each resource gets its own partition and cache reads stay O(1).
118+
///
119+
///
120+
/// Defaults to . This can be set via configuration, e.g.
121+
/// "AzureAd": { "PartitionAppTokenCacheByAudience": true }.
122+
///
123+
///
124+
public bool PartitionAppTokenCacheByAudience { get; set; }
125+
108126
///
109127
/// Enables legacy ADAL cache serialization and deserialization.
110128
/// Performance improvements when working with MSAL only apps.

‎src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#nullable enable
2+
Microsoft.Identity.Web.MicrosoftIdentityOptions.PartitionAppTokenCacheByAudience.get -> bool
3+
Microsoft.Identity.Web.MicrosoftIdentityOptions.PartitionAppTokenCacheByAudience.set -> void
24
Microsoft.Identity.Web.MicrosoftIdentityOptions.UseFastUnboundedCache.get -> bool
35
Microsoft.Identity.Web.MicrosoftIdentityOptions.UseFastUnboundedCache.set -> void
46
Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.OnBackgroundTokenRefreshCompleted.get -> System.Func?

‎src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#nullable enable
2+
Microsoft.Identity.Web.MicrosoftIdentityOptions.PartitionAppTokenCacheByAudience.get -> bool
3+
Microsoft.Identity.Web.MicrosoftIdentityOptions.PartitionAppTokenCacheByAudience.set -> void
24
Microsoft.Identity.Web.MicrosoftIdentityOptions.UseFastUnboundedCache.get -> bool
35
Microsoft.Identity.Web.MicrosoftIdentityOptions.UseFastUnboundedCache.set -> void
46
Microsoft.Identity.Web.TokenAcquisitionExtensionOptions.OnBackgroundTokenRefreshCompleted.get -> System.Func?

‎src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,18 @@ private async Task GetAuthenticationResultForAppInternalAs
978978
.AcquireTokenForClient(new[] { scope }.Except(_scopesRequestedByMsal))
979979
.WithSendX5C(mergedOptions.SendX5C);
980980

981+
// Partition the app token cache by audience (resource) so each downstream resource lands
982+
// in its own cache partition. This keeps AcquireTokenForClient cache reads O(1) for apps
983+
// that acquire tokens for many resources with the same client and tenant. The resource is
984+
// derived from the requested "/.default" scope and added via MSAL's
985+
// WithCachePartitionKey as a non-protocol-affecting cache key component (it is not sent to
986+
// the token endpoint), which partitions both MSAL's internal cache and the serialized
987+
// in-memory cache blob.
988+
if (mergedOptions.PartitionAppTokenCacheByAudience)
989+
{
990+
builder.WithCachePartitionKey(CacheKeyResourceComponent, GetResourceFromScope(scope));
991+
}
992+
981993
if (isTokenBinding)
982994
{
983995
builder.WithMtlsProofOfPossession();
@@ -1157,6 +1169,21 @@ private void AddExtraBodyParametersIfNeeded(TokenAcquisitionOptions tokenAcquisi
11571169
}
11581170
}
11591171

1172+
// Cache key component name used to partition the app token cache by resource/audience
1173+
// (see MicrosoftIdentityOptions.PartitionAppTokenCacheByAudience).
1174+
private const string CacheKeyResourceComponent = "resource";
1175+
1176+
// Derives the resource (audience) from a client-credential scope of the form
1177+
// "/.default". The scope is validated to end with "/.default" earlier in the app
1178+
// token flow, so the suffix is stripped to obtain a stable per-resource partition value.
1179+
private static string GetResourceFromScope(string scope)
1180+
{
1181+
const string defaultSuffix = "/.default";
1182+
return scope.EndsWith(defaultSuffix, StringComparison.OrdinalIgnoreCase)
1183+
? scope.Substring(0, scope.Length - defaultSuffix.Length)
1184+
: scope;
1185+
}
1186+
11601187
private MergedOptions GetMergedOptions(string? authenticationScheme, TokenAcquisitionOptions? tokenAcquisitionOptions)
11611188
{
11621189
MergedOptions mergedOptions;

‎tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,70 @@ public async Task AppToken_InMemoryCache_UseFastUnboundedCache_SkipsSerializatio
234234
Assert.Equal(0, memoryCache.Count);
235235
}
236236

237+
///
238+
/// With MicrosoftIdentityOptions.PartitionAppTokenCacheByAudience = true, app tokens for two
239+
/// different resources are stored under separate cache keys (each resource is its own
240+
/// partition via MSAL's WithCachePartitionKey), so the backing IMemoryCache has one entry per
241+
/// resource.
242+
///
243+
[Fact]
244+
public async Task AppToken_PartitionByAudience_UsesSeparateCacheEntryPerResource()
245+
{
246+
// Arrange
247+
var tokenAcquirerFactory = InitTokenAcquirerFactory();
248+
string uniqueClientId = Guid.NewGuid().ToString();
249+
tokenAcquirerFactory.Services.Configure<MicrosoftIdentityApplicationOptions>(
250+
options => options.ClientId = uniqueClientId);
251+
tokenAcquirerFactory.Services.Configure<MicrosoftIdentityOptions>(
252+
options => options.PartitionAppTokenCacheByAudience = true);
253+
254+
IServiceProvider serviceProvider = tokenAcquirerFactory.Build();
255+
var mockHttpClient = serviceProvider.GetRequiredService<IMsalHttpClientFactory>() as MockHttpClientFactory;
256+
mockHttpClient!.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "token-resource-1"));
257+
mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "token-resource-2"));
258+
259+
var memoryCache = (MemoryCache)serviceProvider.GetRequiredService<IMemoryCache>();
260+
IAuthorizationHeaderProvider authorizationHeaderProvider =
261+
serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>();
262+
263+
// Act — acquire app tokens for two distinct resources.
264+
await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync("https://resource1.example.com/.default");
265+
await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync("https://resource2.example.com/.default");
266+
267+
// Assert — one cache partition (entry) per resource.
268+
Assert.Equal(2, memoryCache.Count);
269+
}
270+
271+
///
272+
/// Without PartitionAppTokenCacheByAudience, app tokens for different resources share the same
273+
/// {clientId}_{tenantId} cache key, so the backing IMemoryCache holds a single (shared) entry.
274+
///
275+
[Fact]
276+
public async Task AppToken_WithoutPartitionByAudience_SharesSingleCacheEntry()
277+
{
278+
// Arrange
279+
var tokenAcquirerFactory = InitTokenAcquirerFactory();
280+
string uniqueClientId = Guid.NewGuid().ToString();
281+
tokenAcquirerFactory.Services.Configure<MicrosoftIdentityApplicationOptions>(
282+
options => options.ClientId = uniqueClientId);
283+
284+
IServiceProvider serviceProvider = tokenAcquirerFactory.Build();
285+
var mockHttpClient = serviceProvider.GetRequiredService<IMsalHttpClientFactory>() as MockHttpClientFactory;
286+
mockHttpClient!.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "token-resource-1"));
287+
mockHttpClient.AddMockHandler(CreateClientCredentialsTokenHandler(accessToken: "token-resource-2"));
288+
289+
var memoryCache = (MemoryCache)serviceProvider.GetRequiredService<IMemoryCache>();
290+
IAuthorizationHeaderProvider authorizationHeaderProvider =
291+
serviceProvider.GetRequiredService<IAuthorizationHeaderProvider>();
292+
293+
// Act
294+
await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync("https://resource1.example.com/.default");
295+
await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync("https://resource2.example.com/.default");
296+
297+
// Assert — both resources share a single {clientId}_{tenantId} partition.
298+
Assert.Equal(1, memoryCache.Count);
299+
}
300+
237301
///
238302
/// Tests that a caught is not re-logged by Microsoft.Identity.Web,
239303
/// since MSAL.NET already logs it. Re-logging produced duplicate log entries.

0 commit comments

Comments
 (0)