diff --git a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs index 74f2221ae3..11d4779a49 100644 --- a/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs +++ b/src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenCommonParameters.cs @@ -40,6 +40,7 @@ internal class AcquireTokenCommonParameters public X509Certificate2 MtlsCertificate { get; internal set; } public List AdditionalCacheParameters { get; set; } public SortedList>> CacheKeyComponents { get; internal set; } + public bool PartitionRefreshToken { get; internal set; } public bool? SendOfflineAccessScope { get; set; } public string FmiPathSuffix { get; internal set; } public string ClientAssertionFmiPath { get; internal set; } diff --git a/src/client/Microsoft.Identity.Client/Cache/Items/MsalRefreshTokenCacheItem.cs b/src/client/Microsoft.Identity.Client/Cache/Items/MsalRefreshTokenCacheItem.cs index 1a75f3baf0..13a7b3aa87 100644 --- a/src/client/Microsoft.Identity.Client/Cache/Items/MsalRefreshTokenCacheItem.cs +++ b/src/client/Microsoft.Identity.Client/Cache/Items/MsalRefreshTokenCacheItem.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System; +using System.Collections.Generic; +using System.Linq; using Microsoft.Identity.Client.Cache.Keys; using Microsoft.Identity.Client.OAuth2; using Microsoft.Identity.Client.Utils; @@ -20,14 +22,16 @@ internal MsalRefreshTokenCacheItem( string preferredCacheEnv, string clientId, MsalTokenResponse response, - string homeAccountId) + string homeAccountId, + SortedList cacheKeyComponents = null) : this( preferredCacheEnv, clientId, response.RefreshToken, response.ClientInfo, response.FamilyId, - homeAccountId) + homeAccountId, + cacheKeyComponents) { } @@ -37,7 +41,8 @@ internal MsalRefreshTokenCacheItem( string secret, string rawClientInfo, string familyId, - string homeAccountId) + string homeAccountId, + SortedList cacheKeyComponents = null) : this() { ClientId = clientId; @@ -47,6 +52,12 @@ internal MsalRefreshTokenCacheItem( FamilyId = familyId; HomeAccountId = homeAccountId; + // Do not partition FRTs — they are shared across apps by design + if (string.IsNullOrWhiteSpace(FamilyId) && cacheKeyComponents != null && cacheKeyComponents.Any()) + { + AdditionalCacheKeyComponents = cacheKeyComponents; + } + InitCacheKey(); } @@ -61,6 +72,20 @@ internal void InitCacheKey() key = $"{HomeAccountId}{d}{Environment}{d}{StorageJsonValues.CredentialTypeRefreshToken}{d}{FamilyId}{d}{d}".ToLowerInvariant(); } + else if (AdditionalCacheKeyComponents != null && AdditionalCacheKeyComponents.Count > 0) + { + // Pass the partition hash through additionalKeys so it is included + // in the ToLowerInvariant() call inside GetCredentialKey, keeping + // cache key casing consistent with the AT partition pattern. + key = MsalCacheKeys.GetCredentialKey( + HomeAccountId, + Environment, + StorageJsonValues.CredentialTypeRefreshToken, + ClientId, + tenantId: null, + scopes: null, + CoreHelpers.ComputeAccessTokenExtCacheKey(AdditionalCacheKeyComponents)); + } else { key = MsalCacheKeys.GetCredentialKey( @@ -79,13 +104,18 @@ internal void InitCacheKey() internal string ToLogString(bool piiEnabled = false) { + string additionalKeys = AdditionalCacheKeyComponents != null && AdditionalCacheKeyComponents.Count > 0 + ? CoreHelpers.ComputeAccessTokenExtCacheKey(AdditionalCacheKeyComponents) + : null; + return MsalCacheKeys.GetCredentialKey( piiEnabled ? HomeAccountId : HomeAccountId?.GetHashCode().ToString(), Environment, StorageJsonValues.CredentialTypeRefreshToken, ClientId, tenantId: null, - scopes: null); + scopes: null, + additionalKeys); } #region iOS @@ -123,6 +153,16 @@ public string GetiOSService() return $"{StorageJsonValues.CredentialTypeRefreshToken}{MsalCacheKeys.CacheKeyDelimiter}{FamilyId}{MsalCacheKeys.CacheKeyDelimiter}{MsalCacheKeys.CacheKeyDelimiter}".ToLowerInvariant(); } + if (AdditionalCacheKeyComponents != null && AdditionalCacheKeyComponents.Count > 0) + { + return MsalCacheKeys.GetiOSServiceKey( + StorageJsonValues.CredentialTypeRefreshToken, + ClientId, + tenantId: null, + scopes: null, + CoreHelpers.ComputeAccessTokenExtCacheKey(AdditionalCacheKeyComponents)); + } + return MsalCacheKeys.GetiOSServiceKey(StorageJsonValues.CredentialTypeRefreshToken, ClientId, tenantId: null, scopes: null); } @@ -144,6 +184,12 @@ public string GetiOSService() /// public bool IsFRT => !string.IsNullOrEmpty(FamilyId); + /// + /// Additional key-value components used to partition this RT in the cache. + /// Never set on FRTs (family refresh tokens are shared across apps). + /// + internal SortedList AdditionalCacheKeyComponents { get; private set; } + public string CacheKey { get; private set; } private Lazy iOSCacheKeyLazy; @@ -165,6 +211,12 @@ internal static MsalRefreshTokenCacheItem FromJObject(JObject j) item.FamilyId = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.FamilyId); item.OboCacheKey = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.UserAssertionHash); + var additionalCacheKeyComponents = JsonHelper.ExtractInnerJsonAsDictionary(j, StorageJsonKeys.CacheExtensions); + if (additionalCacheKeyComponents != null && string.IsNullOrWhiteSpace(item.FamilyId)) + { + item.AdditionalCacheKeyComponents = new SortedList(additionalCacheKeyComponents); + } + item.PopulateFieldsFromJObject(j); item.InitCacheKey(); @@ -176,9 +228,28 @@ internal override JObject ToJObject() var json = base.ToJObject(); SetItemIfValueNotNull(json, StorageJsonKeys.FamilyId, FamilyId); SetItemIfValueNotNull(json, StorageJsonKeys.UserAssertionHash, OboCacheKey); + + if (AdditionalCacheKeyComponents != null && AdditionalCacheKeyComponents.Count > 0) + { + StoreDictionaryInJson(json, StorageJsonKeys.CacheExtensions, AdditionalCacheKeyComponents); + } + return json; } + private static void StoreDictionaryInJson(JObject json, string key, IDictionary values) + { + if (values != null) + { + var innerJson = new JObject(); + foreach (var kvp in values) + { + innerJson[kvp.Key] = kvp.Value; + } + json[key] = innerJson; + } + } + internal string ToJsonString() { return ToJObject().ToString(); diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs index 4726af213e..74effebb1b 100644 --- a/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs +++ b/src/client/Microsoft.Identity.Client/Extensibility/AcquireTokenParameterBuilderExtensions.cs @@ -53,7 +53,7 @@ public static T WithExtraHttpHeaders( /// /// Adds a key-value pair to the token cache key without sending it as a query parameter. - /// Use this to partition cached tokens (e.g., isolating short-lived sessions from regular + /// Use this to partition cached access tokens (e.g., isolating short-lived sessions from regular /// sessions for the same user). Both AcquireTokenByAuthorizationCode and /// AcquireTokenSilent must use the same partition key to match cached entries. /// @@ -66,6 +66,31 @@ public static T WithCachePartitionKey( string key, string value) where T : BaseAbstractAcquireTokenParameterBuilder + { + return builder.WithCachePartitionKey(key, value, partitionRefreshToken: false); + } + + /// + /// Adds a key-value pair to the token cache key without sending it as a query parameter. + /// Use this to partition cached tokens (e.g., isolating short-lived sessions from regular + /// sessions for the same user). Both AcquireTokenByAuthorizationCode and + /// AcquireTokenSilent must use the same partition key to match cached entries. + /// + /// The builder to chain .With methods. + /// The partition key name. + /// The partition key value. + /// + /// When , the refresh token is also stored and looked up using + /// the partition key. When , only the access token is partitioned + /// and the refresh token remains in the shared pool. + /// + /// The builder to chain .With methods. + public static T WithCachePartitionKey( + this BaseAbstractAcquireTokenParameterBuilder builder, + string key, + string value, + bool partitionRefreshToken) + where T : BaseAbstractAcquireTokenParameterBuilder { if (key is null) { @@ -85,6 +110,7 @@ public static T WithCachePartitionKey( builder.CommonParameters.CacheKeyComponents ??= new SortedList>>(); string capturedValue = value; builder.CommonParameters.CacheKeyComponents[key] = (CancellationToken _) => Task.FromResult(capturedValue); + builder.CommonParameters.PartitionRefreshToken |= partitionRefreshToken; return (T)builder; } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs index 6df8e9dded..8de9f86f98 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/AuthenticationRequestParameters.cs @@ -72,6 +72,7 @@ public AuthenticationRequestParameters( HomeAccountId = homeAccountId; CacheKeyComponents = cacheKeyComponents; + PartitionRefreshToken = commonParameters.PartitionRefreshToken; // Defer JSON merge to first access — cache hits never read ClaimsAndClientCapabilities, // so we avoid parsing on the hot path. @@ -165,6 +166,8 @@ public IAuthenticationOperation AuthenticationScheme public SortedList CacheKeyComponents {get; private set; } + public bool PartitionRefreshToken { get; private set; } + #region TODO REMOVE FROM HERE AND USE FROM SPECIFIC REQUEST PARAMETERS // TODO: ideally, these can come from the particular request instance and not be in RequestBase since it's not valid for all requests. diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt index 0e4610510d..415f79fda3 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ -Microsoft.Identity.Client.AppConfig.PoPOptions +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +Microsoft.Identity.Client.AppConfig.PoPOptions Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void -const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt index 0e4610510d..415f79fda3 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ -Microsoft.Identity.Client.AppConfig.PoPOptions +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +Microsoft.Identity.Client.AppConfig.PoPOptions Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void -const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt index 0e4610510d..415f79fda3 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-android/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ -Microsoft.Identity.Client.AppConfig.PoPOptions +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +Microsoft.Identity.Client.AppConfig.PoPOptions Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void -const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt index 0e4610510d..415f79fda3 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0-ios/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ -Microsoft.Identity.Client.AppConfig.PoPOptions +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +Microsoft.Identity.Client.AppConfig.PoPOptions Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void -const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt index 0e4610510d..415f79fda3 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net8.0/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ -Microsoft.Identity.Client.AppConfig.PoPOptions +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +Microsoft.Identity.Client.AppConfig.PoPOptions Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void -const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt index 0e4610510d..415f79fda3 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ -Microsoft.Identity.Client.AppConfig.PoPOptions +const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +Microsoft.Identity.Client.AppConfig.PoPOptions Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.get -> Microsoft.Identity.Client.AppConfig.MtlsBindingStrength Microsoft.Identity.Client.AppConfig.PoPOptions.MinStrength.set -> void Microsoft.Identity.Client.AppConfig.PoPOptions.PoPOptions() -> void -const Microsoft.Identity.Client.MsalError.MinStrengthNotMet = "min_strength_not_met" -> string +static Microsoft.Identity.Client.Extensibility.AcquireTokenParameterBuilderExtensions.WithCachePartitionKey(this Microsoft.Identity.Client.BaseAbstractAcquireTokenParameterBuilder builder, string key, string value, bool partitionRefreshToken) -> T static Microsoft.Identity.Client.ManagedIdentityPopExtensions.WithMtlsProofOfPossession(this Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder builder, Microsoft.Identity.Client.AppConfig.PoPOptions options) -> Microsoft.Identity.Client.AcquireTokenForManagedIdentityParameterBuilder diff --git a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs index 3db2de2141..49b13a4a03 100644 --- a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs +++ b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs @@ -100,7 +100,8 @@ async Task> IToke instanceDiscoveryMetadata.PreferredCache, requestParams.AppConfig.ClientId, response, - homeAccountId) + homeAccountId, + requestParams.PartitionRefreshToken ? requestParams.CacheKeyComponents : null) { OboCacheKey = CacheKeyFactory.GetOboKey(requestParams.LongRunningOboCacheKey, requestParams.UserAssertion), }; @@ -551,6 +552,40 @@ private void FilterTokensByAdditionalKeyComponents(List keep ONLY items with matching components + // request WITHOUT components -> keep ONLY items without components + private void FilterRefreshTokensByAdditionalKeyComponents(List refreshTokens, AuthenticationRequestParameters requestParams) + { + bool requestHasComponents = + requestParams.CacheKeyComponents != null && + requestParams.CacheKeyComponents.Count > 0; + + int countBeforeFilter = refreshTokens.Count; + + if (requestHasComponents) + { + refreshTokens.FilterWithLogging(item => + item.AdditionalCacheKeyComponents != null && + CollectionHelpers.AreDictionariesEqual(item.AdditionalCacheKeyComponents, requestParams.CacheKeyComponents), + requestParams.RequestContext.Logger, + "Filtering RTs by additional key components"); + } + else + { + refreshTokens.FilterWithLogging(item => + item.AdditionalCacheKeyComponents == null || + item.AdditionalCacheKeyComponents.Count == 0, + requestParams.RequestContext.Logger, + "Filtering out RTs that have additional key components"); + } + + if (countBeforeFilter > 0 && refreshTokens.Count == 0) + { + requestParams.RequestContext.Logger.Verbose(() => "No RTs found that match the additional key components filter. "); + } + } + private static void FilterTokensByScopes( List tokenCacheItems, AuthenticationRequestParameters requestParams) @@ -842,6 +877,14 @@ async Task ITokenCacheInternal.FindRefreshTokenAsync( { FilterRefreshTokensByHomeAccountIdOrAssertion(refreshTokens, requestParams, familyId); + // Skip partition filtering for FRT lookups — FRTs are shared across apps + // and are never partitioned, so the partition filter must not apply. + // Also skip when PartitionRefreshToken is not set — the caller only wants AT partition. + if (string.IsNullOrEmpty(familyId) && requestParams.PartitionRefreshToken) + { + FilterRefreshTokensByAdditionalKeyComponents(refreshTokens, requestParams); + } + if (!requestParams.AppConfig.MultiCloudSupportEnabled) { var metadata = diff --git a/tests/Microsoft.Identity.Test.Unit/CacheTests/RefreshTokenCachePartitionTests.cs b/tests/Microsoft.Identity.Test.Unit/CacheTests/RefreshTokenCachePartitionTests.cs new file mode 100644 index 0000000000..149b281731 --- /dev/null +++ b/tests/Microsoft.Identity.Test.Unit/CacheTests/RefreshTokenCachePartitionTests.cs @@ -0,0 +1,546 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Cache.Items; +using Microsoft.Identity.Client.Extensibility; +using Microsoft.Identity.Client.OAuth2; +using Microsoft.Identity.Client.Utils; +using Microsoft.Identity.Test.Common.Core.Helpers; +using Microsoft.Identity.Test.Common.Core.Mocks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.Identity.Test.Unit.CacheTests +{ + [TestClass] + public class RefreshTokenCachePartitionTests + { + [TestMethod] + public void RTWithPartition_CacheKeyIncludesHash() + { + // Arrange + var components = new SortedList + { + { "pk", "pv" } + }; + + // Act + var item = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: null, + TestConstants.HomeAccountId, + components); + + // Assert + Assert.IsNotNull(item.AdditionalCacheKeyComponents); + Assert.HasCount(1, item.AdditionalCacheKeyComponents); + Assert.AreEqual("pv", item.AdditionalCacheKeyComponents["pk"]); + + // The hash is passed through GetCredentialKey which lower-cases the entire key + string hash = CoreHelpers.ComputeAccessTokenExtCacheKey(components).ToLowerInvariant(); + StringAssert.EndsWith(item.CacheKey, "-" + hash, + "Cache key should end with lower-cased partition hash"); + + // The entire cache key must be lower-case (consistent with MSAL convention) + Assert.AreEqual(item.CacheKey, item.CacheKey.ToLowerInvariant(), + "Cache key must be fully lower-cased"); + } + + [TestMethod] + public void RTWithoutPartition_CacheKeyHasNoHash() + { + // Arrange + var components = new SortedList { { "pk", "pv" } }; + + // Act + var nonPartitioned = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: null, + TestConstants.HomeAccountId); + + var partitioned = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: null, + TestConstants.HomeAccountId, + components); + + // Assert + Assert.IsNull(nonPartitioned.AdditionalCacheKeyComponents); + + // Non-partitioned key must be shorter (no hash suffix) + Assert.IsGreaterThan(nonPartitioned.CacheKey.Length, partitioned.CacheKey.Length, + "Partitioned key should be longer than non-partitioned key"); + + // The partition hash must not appear in the non-partitioned key + string hash = CoreHelpers.ComputeAccessTokenExtCacheKey(components).ToLowerInvariant(); + Assert.DoesNotContain(hash, nonPartitioned.CacheKey, + "Non-partitioned key must not contain the partition hash"); + } + + [TestMethod] + public void FRT_NeverGetsPartitioned() + { + // Arrange + var components = new SortedList + { + { "pk", "pv" } + }; + + // Act — create an FRT with partition components + var item = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: "1", + TestConstants.HomeAccountId, + components); + + // Assert — partition should be ignored for FRTs + Assert.IsNull(item.AdditionalCacheKeyComponents); + Assert.IsTrue(item.IsFRT); + + // FRT key should not contain any partition hash (case-insensitive check + // since both the hash and the key are lower-cased by convention) + string hash = CoreHelpers.ComputeAccessTokenExtCacheKey(components).ToLowerInvariant(); + Assert.DoesNotContain(hash, item.CacheKey, + "FRT cache key must not contain partition hash"); + } + + [TestMethod] + public void RTPartition_SerializationRoundTrip() + { + // Arrange + var components = new SortedList + { + { "pk", "pv" } + }; + + var original = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: null, + TestConstants.HomeAccountId, + components); + + // Act — round-trip through JSON + string json = original.ToJsonString(); + var deserialized = MsalRefreshTokenCacheItem.FromJsonString(json); + + // Assert + Assert.IsNotNull(deserialized.AdditionalCacheKeyComponents); + Assert.HasCount(1, deserialized.AdditionalCacheKeyComponents); + Assert.AreEqual("pv", deserialized.AdditionalCacheKeyComponents["pk"]); + Assert.AreEqual(original.CacheKey, deserialized.CacheKey); + } + + [TestMethod] + public void RTPartition_DeserializationWithoutPartition_HasNoComponents() + { + // Arrange — create without partition + var original = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: null, + TestConstants.HomeAccountId); + + // Act — round-trip through JSON + string json = original.ToJsonString(); + var deserialized = MsalRefreshTokenCacheItem.FromJsonString(json); + + // Assert — backward compatible: no components + Assert.IsNull(deserialized.AdditionalCacheKeyComponents); + Assert.AreEqual(original.CacheKey, deserialized.CacheKey); + } + + [TestMethod] + public void FRT_SerializationRoundTrip_NoPartition() + { + // Arrange — FRT with components (should be ignored) + var components = new SortedList + { + { "pk", "pv" } + }; + + var original = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: "1", + TestConstants.HomeAccountId, + components); + + // Act + string json = original.ToJsonString(); + var deserialized = MsalRefreshTokenCacheItem.FromJsonString(json); + + // Assert — FRT never gets partition + Assert.IsNull(deserialized.AdditionalCacheKeyComponents); + Assert.IsTrue(deserialized.IsFRT); + } + + [TestMethod] + public void OldMsalReadsNewJson_PartitionFieldPreservedViaAdditionalFieldsJson() + { + // Scenario: new MSAL writes a partitioned RT. Old MSAL (which doesn't + // know about the "ext" field on RTs) reads it. The unknown field lands + // in AdditionalFieldsJson and must survive re-serialization. + + // Arrange — build JSON as new MSAL would write it + var components = new SortedList { { "pk", "pv" } }; + var partitioned = new MsalRefreshTokenCacheItem( + "login.microsoftonline.com", + TestConstants.ClientId, + "secret-rt", + TestConstants.RawClientId, + familyId: null, + TestConstants.HomeAccountId, + components); + + var json = partitioned.ToJObject(); + + // Verify the ext field is present in the serialized JSON + Assert.IsNotNull(json["ext"], "New MSAL must serialize the ext field"); + + // Act — simulate old MSAL (which doesn't know about the 'ext' field on RTs) by + // renaming it so the current parser treats it as unknown and moves it into AdditionalFieldsJson. + var extValue = json["ext"]; + json.Remove("ext"); + json["unknown_ext"] = extValue; + + var oldStyleItem = MsalRefreshTokenCacheItem.FromJObject(json); + + // Assert — the renamed field should be captured in AdditionalFieldsJson + // because the parser doesn't know about it (simulating old MSAL not knowing "ext") + Assert.IsNotNull(oldStyleItem.AdditionalFieldsJson, + "Unknown fields must be captured in AdditionalFieldsJson"); + Assert.Contains("unknown_ext", oldStyleItem.AdditionalFieldsJson, + "Renamed ext field must survive as an additional field"); + Assert.IsNull(oldStyleItem.AdditionalCacheKeyComponents, + "Old MSAL must not populate partition components from renamed field"); + + // Re-serialize and verify the unknown field is preserved + var reserializedJson = oldStyleItem.ToJObject(); + Assert.IsNotNull(reserializedJson["unknown_ext"], + "Unknown field must survive old MSAL round-trip via AdditionalFieldsJson"); + } + + [TestMethod] + public void NewMsalReadsOldJson_MissingPartitionFieldYieldsNullComponents() + { + // Scenario: old MSAL wrote an RT without cache_extensions. + // New MSAL reads it and should treat it as non-partitioned. + + // Arrange — manually build JSON without cache_extensions + var json = new System.Text.Json.Nodes.JsonObject + { + ["home_account_id"] = TestConstants.HomeAccountId, + ["environment"] = "login.microsoftonline.com", + ["credential_type"] = "RefreshToken", + ["client_id"] = TestConstants.ClientId, + ["secret"] = "old-rt-secret", + ["family_id"] = "" + }; + + // Act + var item = MsalRefreshTokenCacheItem.FromJObject(json); + + // Assert + Assert.IsNull(item.AdditionalCacheKeyComponents); + Assert.AreEqual("old-rt-secret", item.Secret); + } + + [TestMethod] + public async Task AcquireTokenByAuthCode_WithPartition_StoresPartitionedRT_Async() + { + using (var httpManager = new MockHttpManager()) + { + // Arrange + const string partitionKey = "transfer_id"; + const string partitionValue = "abc123"; + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new System.Uri(ClientApplicationBase.DefaultAuthority), true) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(httpManager) + .WithInstanceDiscovery(false) + .BuildConcrete(); + + httpManager.AddSuccessTokenResponseMockHandlerForPost(); + + // Act — acquire with partition + var result = await app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode) + .WithCachePartitionKey(partitionKey, partitionValue, partitionRefreshToken: true) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + // Verify RT was stored with partition + var rtItems = app.UserTokenCacheInternal.Accessor.GetAllRefreshTokens(); + Assert.AreNotEqual(0, rtItems.Count, "At least one RT should be in cache"); + + var partitionedRt = rtItems.FirstOrDefault(rt => rt.AdditionalCacheKeyComponents != null); + Assert.IsNotNull(partitionedRt, "Expected a partitioned RT in the cache"); + Assert.AreEqual(partitionValue, partitionedRt.AdditionalCacheKeyComponents[partitionKey]); + } + } + + [TestMethod] + public async Task AcquireTokenByAuthCode_WithoutPartition_StoresNonPartitionedRT_Async() + { + using (var httpManager = new MockHttpManager()) + { + // Arrange + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new System.Uri(ClientApplicationBase.DefaultAuthority), true) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(httpManager) + .WithInstanceDiscovery(false) + .BuildConcrete(); + + httpManager.AddSuccessTokenResponseMockHandlerForPost(); + + // Act — acquire without partition + var result = await app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert + Assert.IsNotNull(result); + + var rtItems = app.UserTokenCacheInternal.Accessor.GetAllRefreshTokens(); + Assert.AreNotEqual(0, rtItems.Count, "At least one RT should be in cache"); + Assert.IsTrue(rtItems.All(rt => rt.AdditionalCacheKeyComponents is null), + "No RT should have partition components when acquired without partition"); + } + } + + [TestMethod] + public async Task AcquireTokenSilent_WithPartition_FindsPartitionedRT_Async() + { + using (var httpManager = new MockHttpManager()) + { + // Arrange + const string partitionKey = "session_type"; + const string partitionValue = "transfer"; + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new System.Uri(ClientApplicationBase.DefaultAuthority), true) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(httpManager) + .WithInstanceDiscovery(false) + .BuildConcrete(); + + // Acquire with partition to seed both AT and RT in cache + httpManager.AddSuccessTokenResponseMockHandlerForPost(); + var result = await app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode) + .WithCachePartitionKey(partitionKey, partitionValue, partitionRefreshToken: true) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + + // Expire all ATs so silent must use the RT + TokenCacheHelper.ExpireAllAccessTokens(app.UserTokenCacheInternal); + + // Mock the refresh token grant response + var handler = httpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant); + handler.ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken } + }; + + // Act: silent acquire with partition should find the partitioned RT + var account = await app.GetAccountAsync(result.Account.HomeAccountId.Identifier).ConfigureAwait(false); + var silentResult = await app.AcquireTokenSilent(TestConstants.s_scope, account) + .WithCachePartitionKey(partitionKey, partitionValue, partitionRefreshToken: true) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert: token came from IDP via RT refresh + Assert.AreEqual(TokenSource.IdentityProvider, silentResult.AuthenticationResultMetadata.TokenSource); + } + } + + [TestMethod] + public async Task AcquireTokenSilent_WithoutRtPartition_FindsPartitionedRT_BackwardCompat_Async() + { + using (var httpManager = new MockHttpManager()) + { + // Arrange: a partitioned RT exists in cache, but the silent caller + // does not opt into RT partitioning. The RT filter should not engage, + // so the partitioned RT is still found (backward compat). + const string partitionKey = "session_type"; + const string partitionValue = "transfer"; + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new System.Uri(ClientApplicationBase.DefaultAuthority), true) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(httpManager) + .WithInstanceDiscovery(false) + .BuildConcrete(); + + // Seed a partitioned RT + httpManager.AddSuccessTokenResponseMockHandlerForPost(); + var result = await app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode) + .WithCachePartitionKey(partitionKey, partitionValue, partitionRefreshToken: true) + .ExecuteAsync() + .ConfigureAwait(false); + + // Expire all ATs so silent must use the RT + TokenCacheHelper.ExpireAllAccessTokens(app.UserTokenCacheInternal); + + // Mock the refresh token grant response + var handler = httpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant); + handler.ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken } + }; + + // Act: silent acquire WITHOUT partitionRefreshToken should still find the RT + var account = await app.GetAccountAsync(result.Account.HomeAccountId.Identifier).ConfigureAwait(false); + var silentResult = await app.AcquireTokenSilent(TestConstants.s_scope, account) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert: token came from IDP via RT refresh (filter was not engaged) + Assert.AreEqual(TokenSource.IdentityProvider, silentResult.AuthenticationResultMetadata.TokenSource); + } + } + + [TestMethod] + public async Task AcquireTokenSilent_WithDifferentPartition_DoesNotFindPartitionedRT_Async() + { + using (var httpManager = new MockHttpManager()) + { + // Arrange: only a partitioned RT exists in cache + const string partitionKey = "session_type"; + const string partitionValue = "transfer"; + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new System.Uri(ClientApplicationBase.DefaultAuthority), true) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(httpManager) + .WithInstanceDiscovery(false) + .BuildConcrete(); + + // Seed a partitioned RT + httpManager.AddSuccessTokenResponseMockHandlerForPost(); + var result = await app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode) + .WithCachePartitionKey(partitionKey, partitionValue, partitionRefreshToken: true) + .ExecuteAsync() + .ConfigureAwait(false); + + // Expire all ATs + TokenCacheHelper.ExpireAllAccessTokens(app.UserTokenCacheInternal); + + // Act: silent acquire with partitionRefreshToken but a DIFFERENT value should NOT find it + var account = await app.GetAccountAsync(result.Account.HomeAccountId.Identifier).ConfigureAwait(false); + var ex = await AssertException.TaskThrowsAsync( + () => app.AcquireTokenSilent(TestConstants.s_scope, account) + .WithCachePartitionKey(partitionKey, "different_value", partitionRefreshToken: true) + .ExecuteAsync()) + .ConfigureAwait(false); + + // Assert: interaction required because no matching RT was found + Assert.AreEqual(MsalError.NoTokensFoundError, ex.ErrorCode); + } + } + + [TestMethod] + public async Task AcquireTokenSilent_MixedCache_PartitionIsolatesCorrectRT_Async() + { + using (var httpManager = new MockHttpManager()) + { + // Arrange: both a partitioned and non-partitioned RT in the same cache + const string partitionKey = "session_type"; + const string partitionValue = "transfer"; + + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new System.Uri(ClientApplicationBase.DefaultAuthority), true) + .WithClientSecret(TestConstants.ClientSecret) + .WithHttpManager(httpManager) + .WithInstanceDiscovery(false) + .BuildConcrete(); + + // Seed non-partitioned RT (regular OIDC session) + httpManager.AddSuccessTokenResponseMockHandlerForPost(); + var regularResult = await app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode) + .ExecuteAsync() + .ConfigureAwait(false); + + // Seed partitioned RT (transfer token session) + httpManager.AddSuccessTokenResponseMockHandlerForPost(); + var partitionedResult = await app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, "transfer-code") + .WithCachePartitionKey(partitionKey, partitionValue, partitionRefreshToken: true) + .ExecuteAsync() + .ConfigureAwait(false); + + // Verify both RTs exist + var rtItems = app.UserTokenCacheInternal.Accessor.GetAllRefreshTokens(); + var nonPartitioned = rtItems.Where(rt => rt.AdditionalCacheKeyComponents is null).ToList(); + var partitioned = rtItems.Where(rt => rt.AdditionalCacheKeyComponents != null).ToList(); + Assert.AreNotEqual(0, nonPartitioned.Count, "Non-partitioned RT expected"); + Assert.AreNotEqual(0, partitioned.Count, "Partitioned RT expected"); + + // Expire all ATs so silent must go through RT + TokenCacheHelper.ExpireAllAccessTokens(app.UserTokenCacheInternal); + + // Silent with partition: should use partitioned RT + var partitionHandler = httpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant); + partitionHandler.ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken } + }; + + var account = await app.GetAccountAsync(regularResult.Account.HomeAccountId.Identifier).ConfigureAwait(false); + var silentPartitioned = await app.AcquireTokenSilent(TestConstants.s_scope, account) + .WithCachePartitionKey(partitionKey, partitionValue, partitionRefreshToken: true) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, silentPartitioned.AuthenticationResultMetadata.TokenSource); + + // Expire ATs again + TokenCacheHelper.ExpireAllAccessTokens(app.UserTokenCacheInternal); + + // Silent without partition: should use non-partitioned RT + var regularHandler = httpManager.AddSuccessTokenResponseMockHandlerForPost( + TestConstants.AuthorityUtidTenant); + regularHandler.ExpectedPostData = new Dictionary + { + { OAuth2Parameter.GrantType, OAuth2GrantType.RefreshToken } + }; + + var silentRegular = await app.AcquireTokenSilent(TestConstants.s_scope, account) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, silentRegular.AuthenticationResultMetadata.TokenSource); + } + } + } +} diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/WithCachePartitionKeyTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/WithCachePartitionKeyTests.cs index 90b878881f..9498cc68fc 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/WithCachePartitionKeyTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/WithCachePartitionKeyTests.cs @@ -120,6 +120,27 @@ public void WithCachePartitionKey_ThrowsOnNullValue() Assert.AreEqual("value", exception.ParamName); } + [TestMethod] + public void WithCachePartitionKey_PartitionRefreshToken_StickyAcrossChainedCalls() + { + // Arrange + var app = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithAuthority(new Uri(ClientApplicationBase.DefaultAuthority), true) + .WithClientSecret(TestConstants.ClientSecret) + .BuildConcrete(); + + // Act: first call enables RT partition, second call adds another key without it + var builder = app.AcquireTokenByAuthorizationCode(TestConstants.s_scope, "code") + .WithCachePartitionKey("k1", "v1", partitionRefreshToken: true) + .WithCachePartitionKey("k2", "v2"); + + // Assert: PartitionRefreshToken must remain true + var commonParameters = GetCommonParameters(builder); + Assert.IsTrue(commonParameters.PartitionRefreshToken, + "PartitionRefreshToken must stay true once set, even if a later call omits it"); + Assert.HasCount(2, commonParameters.CacheKeyComponents); + } + private static AcquireTokenCommonParameters GetCommonParameters(object builder) { Type currentType = builder.GetType();