diff --git a/Directory.Build.props b/Directory.Build.props index 816fcbd05..f88d98466 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -80,7 +80,7 @@ 8.18.0 - 4.84.0 + 4.84.1 12.0.0 3.3.0 4.7.2 diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/Extensibility/TokenAcquisitionOptionsExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/Extensibility/TokenAcquisitionOptionsExtensions.cs new file mode 100644 index 000000000..be8f9398e --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/Extensibility/TokenAcquisitionOptionsExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; + +namespace Microsoft.Identity.Web.Extensibility +{ + /// + /// Extension methods for in extensibility scenarios. + /// + public static class TokenAcquisitionOptionsExtensions + { + /// + /// Sets cache partition key-value pairs on the options. When set, the token cache + /// lookup and storage will include these components, isolating cached tokens from + /// entries that have different (or no) partition keys. + /// + /// The token acquisition options. + /// The partition key-value pairs. + /// The options instance for chaining. + public static TokenAcquisitionOptions WithCachePartitionKeys( + this TokenAcquisitionOptions options, + IDictionary partitionKeys) + { + options.CachePartitionKeys = partitionKeys; + return options; + } + } +} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/IConfidentialClientApplicationProvider.cs b/src/Microsoft.Identity.Web.TokenAcquisition/IConfidentialClientApplicationProvider.cs new file mode 100644 index 000000000..b33de9622 --- /dev/null +++ b/src/Microsoft.Identity.Web.TokenAcquisition/IConfidentialClientApplicationProvider.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Threading.Tasks; +using Microsoft.Identity.Client; + +namespace Microsoft.Identity.Web.Extensibility +{ + /// + /// Provides access to the managed by + /// Microsoft.Identity.Web for a given authentication scheme. Use this when you need + /// to call MSAL directly (e.g., for custom token acquisition flows) while reusing + /// the same application instance, credentials, and token cache that IdWeb manages. + /// + public interface IConfidentialClientApplicationProvider + { + /// + /// Gets the for the specified authentication scheme. + /// + /// + /// The authentication scheme name. If null, the effective default scheme is used. + /// + /// The confidential client application instance. + Task GetConfidentialClientApplicationAsync( + string? authenticationScheme = null); + } +} diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt index 7dc5c5811..5824eb06f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetCore/PublicAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +Microsoft.Identity.Web.Extensibility.IConfidentialClientApplicationProvider +Microsoft.Identity.Web.Extensibility.IConfidentialClientApplicationProvider.GetConfidentialClientApplicationAsync(string? authenticationScheme = null) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.Extensibility.TokenAcquisitionOptionsExtensions +static Microsoft.Identity.Web.Extensibility.TokenAcquisitionOptionsExtensions.WithCachePartitionKeys(this Microsoft.Identity.Web.TokenAcquisitionOptions! options, System.Collections.Generic.IDictionary! partitionKeys) -> Microsoft.Identity.Web.TokenAcquisitionOptions! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt index 7dc5c5811..5824eb06f 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.TokenAcquisition/PublicAPI/NetFramework/PublicAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +Microsoft.Identity.Web.Extensibility.IConfidentialClientApplicationProvider +Microsoft.Identity.Web.Extensibility.IConfidentialClientApplicationProvider.GetConfidentialClientApplicationAsync(string? authenticationScheme = null) -> System.Threading.Tasks.Task! +Microsoft.Identity.Web.Extensibility.TokenAcquisitionOptionsExtensions +static Microsoft.Identity.Web.Extensibility.TokenAcquisitionOptionsExtensions.WithCachePartitionKeys(this Microsoft.Identity.Web.TokenAcquisitionOptions! options, System.Collections.Generic.IDictionary! partitionKeys) -> Microsoft.Identity.Web.TokenAcquisitionOptions! diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs index 79c22d8bf..4fd3ff838 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.Hosts; namespace Microsoft.Identity.Web @@ -71,6 +72,7 @@ public static IServiceCollection AddTokenAcquisition( ServiceDescriptor? tokenAcquisitionService = services.FirstOrDefault(s => s.ServiceType == typeof(ITokenAcquisition)); ServiceDescriptor? tokenAcquisitionInternalService = services.FirstOrDefault(s => s.ServiceType == typeof(ITokenAcquisitionInternal)); + ServiceDescriptor? ccaProviderService = services.FirstOrDefault(s => s.ServiceType == typeof(IConfidentialClientApplicationProvider)); ServiceDescriptor? tokenAcquisitionhost = services.FirstOrDefault(s => s.ServiceType == typeof(ITokenAcquisitionHost)); ServiceDescriptor? authenticationHeaderCreator = services.FirstOrDefault(s => s.ServiceType == typeof(IAuthorizationHeaderProvider)); ServiceDescriptor? tokenAcquirerFactory = services.FirstOrDefault(s => s.ServiceType == typeof(ITokenAcquirerFactory)); @@ -89,6 +91,11 @@ public static IServiceCollection AddTokenAcquisition( services.Remove(authenticationHeaderCreator); services.Remove(authSchemeInfoProvider); + if (ccaProviderService != null) + { + services.Remove(ccaProviderService); + } + if (credentialsProviderService != null) { services.Remove(credentialsProviderService); @@ -137,6 +144,7 @@ public static IServiceCollection AddTokenAcquisition( } #endif services.AddSingleton(s => (ITokenAcquisitionInternal)s.GetRequiredService()); + services.AddSingleton(s => (IConfidentialClientApplicationProvider)s.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); @@ -171,6 +179,7 @@ public static IServiceCollection AddTokenAcquisition( } #endif services.AddScoped(s => (ITokenAcquisitionInternal)s.GetRequiredService()); + services.AddScoped(s => (IConfidentialClientApplicationProvider)s.GetRequiredService()); services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs index b4fdda8c4..eff4fb715 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisition.cs @@ -21,6 +21,7 @@ using Microsoft.Identity.Client; using Microsoft.Identity.Client.Extensibility; using Microsoft.Identity.Web.Experimental; +using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.TestOnly; using Microsoft.Identity.Web.TokenCacheProviders; using Microsoft.Identity.Web.TokenCacheProviders.InMemory; @@ -38,9 +39,9 @@ namespace Microsoft.Identity.Web * Treat as a public member. */ #if NETSTANDARD2_0 || NET462 || NET472 - internal partial class TokenAcquisition : ITokenAcquisitionInternal + internal partial class TokenAcquisition : ITokenAcquisitionInternal, IConfidentialClientApplicationProvider #else - internal partial class TokenAcquisition + internal partial class TokenAcquisition : IConfidentialClientApplicationProvider #endif { #if NETSTANDARD2_0 || NET462 || NET472 @@ -443,6 +444,8 @@ private async Task GetAuthenticationResultForUserInternalA var account = await application.GetAccountAsync(user.GetMsalAccountId()).ConfigureAwait(false); // Silent flow + // Note: CachePartitionKeys from TokenAcquisitionOptions is not applied here. + // This path is used by ROPC, which does not support TokenAcquisitionOptions. return await application.AcquireTokenSilent( scopes.Except(_scopesRequestedByMsal), account) @@ -1038,7 +1041,15 @@ private bool IsInvalidClientCertificateOrSignedAssertionError(MsalServiceExcepti return clientClaims; } -#pragma warning disable RS0051 // Add internal types and members to the declared API + /// + public async Task GetConfidentialClientApplicationAsync( + string? authenticationScheme = null) + { + MergedOptions mergedOptions = _tokenAcquisitionHost.GetOptions(authenticationScheme, out _); + return await GetOrBuildConfidentialClientApplicationAsync(mergedOptions, isTokenBinding: false) + .ConfigureAwait(false); + } + internal /* for testing */ async Task GetOrBuildConfidentialClientApplicationAsync( MergedOptions mergedOptions, bool isTokenBinding) @@ -1562,6 +1573,13 @@ private Task GetAuthenticationResultForWebAppWithAccountFr { builder.WithProofOfPossession(tokenAcquisitionOptions.PoPConfiguration); } + if (tokenAcquisitionOptions.CachePartitionKeys != null) + { + foreach (var kvp in tokenAcquisitionOptions.CachePartitionKeys) + { + builder.WithCachePartitionKey(kvp.Key, kvp.Value); + } + } } // Acquire an access token as a B2C authority diff --git a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisitionOptions.cs b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisitionOptions.cs index 54792b04a..0ab4cfb36 100644 --- a/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisitionOptions.cs +++ b/src/Microsoft.Identity.Web.TokenAcquisition/TokenAcquisitionOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using System.Collections.Generic; using System.Threading; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client.AppConfig; @@ -25,6 +26,8 @@ public class TokenAcquisitionOptions : AcquireTokenOptions /// public CancellationToken CancellationToken { get; set; } = CancellationToken.None; + internal IDictionary? CachePartitionKeys { get; set; } + /// /// Clone the options (to be able to override them). /// @@ -46,7 +49,10 @@ public class TokenAcquisitionOptions : AcquireTokenOptions CancellationToken = CancellationToken, LongRunningWebApiSessionKey = LongRunningWebApiSessionKey, ManagedIdentity = ManagedIdentity, - FmiPath = FmiPath + FmiPath = FmiPath, + CachePartitionKeys = CachePartitionKeys != null + ? new Dictionary(CachePartitionKeys) + : null }; } } diff --git a/tests/Microsoft.Identity.Web.Test/ServiceCollectionExtensionsTests.cs b/tests/Microsoft.Identity.Web.Test/ServiceCollectionExtensionsTests.cs index b875facdb..4f4b844af 100644 --- a/tests/Microsoft.Identity.Web.Test/ServiceCollectionExtensionsTests.cs +++ b/tests/Microsoft.Identity.Web.Test/ServiceCollectionExtensionsTests.cs @@ -11,7 +11,9 @@ using Microsoft.Extensions.Options; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.Test.Common; +using Microsoft.Identity.Web.TokenCacheProviders.InMemory; using Xunit; namespace Microsoft.Identity.Web.Test @@ -25,7 +27,9 @@ public void AddTokenAcquisition_Sdk_AddsWithCorrectLifetime() var services = new ServiceCollection(); services.AddTokenAcquisition(); - ServiceDescriptor[] orderedServices = services.OrderBy(s => s.ServiceType.FullName).ToArray(); + ServiceDescriptor[] orderedServices = services + .OrderBy(s => s.ServiceType.FullName) + .ToArray(); Assert.Collection( orderedServices, @@ -92,6 +96,14 @@ public void AddTokenAcquisition_Sdk_AddsWithCorrectLifetime() Assert.Null(actual.ImplementationFactory); }, actual => + { + Assert.Equal(ServiceLifetime.Scoped, actual.Lifetime); + Assert.Equal(typeof(IConfidentialClientApplicationProvider), actual.ServiceType); + Assert.Null(actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.NotNull(actual.ImplementationFactory); + }, + actual => { Assert.Equal(typeof(ICredentialsProvider), actual.ServiceType); Assert.Equal(typeof(CredentialsProvider), actual.ImplementationType); @@ -143,7 +155,7 @@ public void AddTokenAcquisition_Sdk_SupportsKeyedServices() services.AddTokenAcquisition(); // Verify the number of services added by AddTokenAcquisition (ignoring the service we added here). - Assert.Equal(13, services.Count(t => t.ServiceType != typeof(ServiceCollectionExtensionsTests))); + Assert.Equal(14, services.Count(t => t.ServiceType != typeof(ServiceCollectionExtensionsTests))); } #endif @@ -161,6 +173,43 @@ public void AddTokenAcquisition_AbleToOverrideICredentialsLoader() Assert.Single(orderedServices, s => s.ServiceType == typeof(ICredentialsLoader)); } + [Fact] + public void AddTokenAcquisition_RegistersIConfidentialClientApplicationProvider() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddTokenAcquisition(); + + // Assert + ServiceDescriptor serviceDescriptor = Assert.Single(services, s => s.ServiceType == typeof(IConfidentialClientApplicationProvider)); + Assert.Equal(ServiceLifetime.Scoped, serviceDescriptor.Lifetime); + Assert.Null(serviceDescriptor.ImplementationType); + Assert.Null(serviceDescriptor.ImplementationInstance); + Assert.NotNull(serviceDescriptor.ImplementationFactory); + } + + [Fact] + public void AddTokenAcquisition_ResolvesIConfidentialClientApplicationProviderToTokenAcquisition() + { + // Arrange + var services = new ServiceCollection(); + services.AddTokenAcquisition(); + services.AddInMemoryTokenCaches(); + services.AddHttpClient(); + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + using IServiceScope serviceScope = serviceProvider.CreateScope(); + + // Act + var tokenAcquisition = serviceScope.ServiceProvider.GetRequiredService(); + var confidentialClientApplicationProvider = serviceScope.ServiceProvider.GetRequiredService(); + + // Assert + Assert.Same(tokenAcquisition, confidentialClientApplicationProvider); + Assert.IsAssignableFrom(confidentialClientApplicationProvider); + } + [Fact] public void AddHttpContextAccessor_ThrowsWithoutServices() { @@ -181,7 +230,9 @@ public void AddTokenAcquisitionCalledTwice_RegistersTokenAcquisitionOnlyAsSingle services.AddTokenAcquisition(isTokenAcquisitionSingleton: true); // Assert - var orderedServices = services.OrderBy(s => s.ServiceType.FullName).ToList(); + var orderedServices = services + .OrderBy(s => s.ServiceType.FullName) + .ToList(); // Check that the first service is registered as singleton Assert.Collection( @@ -248,6 +299,14 @@ public void AddTokenAcquisitionCalledTwice_RegistersTokenAcquisitionOnlyAsSingle Assert.Null(actual.ImplementationFactory); }, actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(IConfidentialClientApplicationProvider), actual.ServiceType); + Assert.Null(actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.NotNull(actual.ImplementationFactory); + }, + actual => { Assert.Equal(typeof(ICredentialsProvider), actual.ServiceType); Assert.Equal(typeof(CredentialsProvider), actual.ImplementationType); diff --git a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs index 65e4ee2d1..ef66fa427 100644 --- a/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs +++ b/tests/Microsoft.Identity.Web.Test/TokenAcquisitionTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Identity.Abstractions; using Microsoft.Identity.Client; +using Microsoft.Identity.Web.Extensibility; using Microsoft.Identity.Web.Test.Common; using Microsoft.Identity.Web.Test.Common.Mocks; using Microsoft.Identity.Web.TestOnly; @@ -24,6 +25,39 @@ public class TokenAcquisitionTests private const string TenantId = "tenant-id"; private const string AppHomeTenantId = "app-home-tenant-id"; + [Fact] + public void CachePartitionKeys_DefaultsToNull() + { + // Arrange + var options = new TokenAcquisitionOptions(); + + // Act + IDictionary? cachePartitionKeys = options.CachePartitionKeys; + + // Assert + Assert.Null(cachePartitionKeys); + } + + [Fact] + public void CachePartitionKeys_CanBeSet() + { + // Arrange + IDictionary cachePartitionKeys = new Dictionary + { + ["tenant"] = "contoso", + ["user"] = "alice" + }; + + // Act + var options = new TokenAcquisitionOptions() + .WithCachePartitionKeys(cachePartitionKeys); + + // Assert + Assert.Same(cachePartitionKeys, options.CachePartitionKeys); + Assert.Equal("contoso", options.CachePartitionKeys!["tenant"]); + Assert.Equal("alice", options.CachePartitionKeys["user"]); + } + [Theory] [InlineData(null, null, null, null)] [InlineData(null, null, AppHomeTenantId, null)]