diff --git a/src/client/Microsoft.Identity.Client/AuthScheme/MsalCacheValidationData.cs b/src/client/Microsoft.Identity.Client/AuthScheme/MsalCacheValidationData.cs index a0e95d28c8..48e20c8c7f 100644 --- a/src/client/Microsoft.Identity.Client/AuthScheme/MsalCacheValidationData.cs +++ b/src/client/Microsoft.Identity.Client/AuthScheme/MsalCacheValidationData.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Threading; namespace Microsoft.Identity.Client.AuthScheme { @@ -14,5 +15,10 @@ public class MsalCacheValidationData /// Gets the persisted parameters addded to the cache items. /// public IDictionary PersistedCacheParameters { get; internal set; } + + /// + /// The cancellation token used to cancel cache validation operations. + /// + public CancellationToken cancellationToken { get; internal set; } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs index c7b9f77469..4b5cd57730 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs @@ -7,7 +7,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Identity.Client.ApiConfig.Parameters; -using Microsoft.Identity.Client.AuthScheme; using Microsoft.Identity.Client.Cache.Items; using Microsoft.Identity.Client.Core; using Microsoft.Identity.Client.Extensibility; @@ -72,20 +71,8 @@ protected override async Task ExecuteAsync(CancellationTok MsalAccessTokenCacheItem cachedAccessTokenItem = await GetCachedAccessTokenAsync().ConfigureAwait(false); - // Validate the cached token using the authentication operation - if (AuthenticationRequestParameters.AuthenticationScheme != null && - cachedAccessTokenItem != null && - AuthenticationRequestParameters.AuthenticationScheme is IAuthenticationOperation2 authOp2) - { - var cacheValidationData = new MsalCacheValidationData(); - cacheValidationData.PersistedCacheParameters = cachedAccessTokenItem.PersistedCacheParameters; - - if (!await authOp2.ValidateCachedTokenAsync(cacheValidationData).ConfigureAwait(false)) - { - logger.Info("[ClientCredentialRequest] Cached token failed authentication operation validation."); - cachedAccessTokenItem = null; - } - } + cachedAccessTokenItem = await ValidateCachedAccessTokenAsync( + AuthenticationRequestParameters, cachedAccessTokenItem, nameof(ClientCredentialRequest)).ConfigureAwait(false); // No access token or cached access token needs to be refreshed if (cachedAccessTokenItem != null) diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs index f978034031..8f1e5dfc1c 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs @@ -71,6 +71,9 @@ protected override async Task ExecuteAsync(CancellationTok cachedAccessToken = await CacheManager.FindAccessTokenAsync().ConfigureAwait(false); } + cachedAccessToken = await ValidateCachedAccessTokenAsync( + AuthenticationRequestParameters, cachedAccessToken, nameof(OnBehalfOfRequest)).ConfigureAwait(false); + if (cachedAccessToken != null) { var cachedIdToken = await CacheManager.GetIdTokenCacheItemAsync(cachedAccessToken).ConfigureAwait(false); diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs index 9a4e50dd6a..c8269bb7cd 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs @@ -570,5 +570,31 @@ private static RegionDetails CreateRegionDetails(ApiEvent apiEvent) apiEvent.RegionUsed, apiEvent.RegionDiscoveryFailureReason); } + + /// + /// Validates a cached access token using the authentication operation, if the scheme implements . + /// Returns the original cache item if validation passes or is not applicable, or null if validation fails. + /// + internal static async Task ValidateCachedAccessTokenAsync( + AuthenticationRequestParameters authenticationRequestParameters, + MsalAccessTokenCacheItem cachedAccessTokenItem, + string requestType) + { + if (cachedAccessTokenItem != null && + authenticationRequestParameters.AuthenticationScheme is IAuthenticationOperation2 authOp2) + { + var cacheValidationData = new MsalCacheValidationData(); + cacheValidationData.PersistedCacheParameters = cachedAccessTokenItem.PersistedCacheParameters; + + if (!await authOp2.ValidateCachedTokenAsync(cacheValidationData).ConfigureAwait(false)) + { + authenticationRequestParameters.RequestContext.Logger.Info( + $"[{requestType}] Cached token failed authentication operation validation."); + return null; + } + } + + return cachedAccessTokenItem; + } } } diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/Silent/CacheSilentStrategy.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/Silent/CacheSilentStrategy.cs index 8a6fcdd48e..9d85da5b17 100644 --- a/src/client/Microsoft.Identity.Client/Internal/Requests/Silent/CacheSilentStrategy.cs +++ b/src/client/Microsoft.Identity.Client/Internal/Requests/Silent/CacheSilentStrategy.cs @@ -48,6 +48,9 @@ public async Task ExecuteAsync(CancellationToken cancellat { cachedAccessTokenItem = await CacheManager.FindAccessTokenAsync().ConfigureAwait(false); + cachedAccessTokenItem = await RequestBase.ValidateCachedAccessTokenAsync( + AuthenticationRequestParameters, cachedAccessTokenItem, nameof(CacheSilentStrategy)).ConfigureAwait(false); + if (cachedAccessTokenItem != null) { logger.Info("Returning access token found in cache. RefreshOn exists ? " 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 e69de29bb2..5f1ab1006d 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken \ No newline at end of file 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 e69de29bb2..49f0d092b6 100644 --- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt +++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken 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 e69de29bb2..49f0d092b6 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken 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 e69de29bb2..49f0d092b6 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken 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 e69de29bb2..49f0d092b6 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken 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 e69de29bb2..49f0d092b6 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 @@ -0,0 +1 @@ +Microsoft.Identity.Client.AuthScheme.MsalCacheValidationData.cancellationToken.get -> System.Threading.CancellationToken diff --git a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/AuthenticationOperationTests.cs b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/AuthenticationOperationTests.cs index cb48b0ebb6..5c4c549dae 100644 --- a/tests/Microsoft.Identity.Test.Unit/PublicApiTests/AuthenticationOperationTests.cs +++ b/tests/Microsoft.Identity.Test.Unit/PublicApiTests/AuthenticationOperationTests.cs @@ -460,10 +460,210 @@ public async Task ValidateCachedTokenAsync_WithNullCachedItem_ValidationNotCalle // Assert - validation was never called because no cached token existed await authScheme.DidNotReceive().ValidateCachedTokenAsync(Arg.Any()) .ConfigureAwait(false); - + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); Assert.AreEqual(0, httpManager.QueueSize); } } + + [TestMethod] + public async Task ValidateCachedTokenAsync_OBO_WhenValidationFails_CacheIsIgnoredAsync() + { + // Arrange + var authScheme = Substitute.For(); + authScheme.AuthorizationHeaderPrefix.Returns("CustomToken"); + authScheme.AccessTokenType.Returns("bearer"); + authScheme.KeyId.Returns("keyid"); + authScheme.GetTokenRequestParams().Returns(new Dictionary() { { "tokenParam", "tokenParamValue" } }); + + authScheme.WhenForAnyArgs(x => x.FormatResultAsync(default, default)) + .Do(x => ((AuthenticationResult)x[0]).AccessToken = "validated_" + ((AuthenticationResult)x[0]).AccessToken); + + // Validation fails - cached token should be ignored + authScheme.ValidateCachedTokenAsync(Arg.Any()) + .Returns(Task.FromResult(false)); + + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // First request - acquire initial token via OBO + httpManager.AddSuccessTokenResponseMockHandlerForPost(TestConstants.AuthorityCommonTenant); + + UserAssertion userAssertion = new UserAssertion(TestConstants.DefaultAccessToken); + var result1 = await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithAuthenticationOperation(authScheme) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + + // Second request - validation fails, so a new token should be acquired + httpManager.AddSuccessTokenResponseMockHandlerForPost(TestConstants.AuthorityCommonTenant); + + var result2 = await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithAuthenticationOperation(authScheme) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource); + await authScheme.Received(1).ValidateCachedTokenAsync(Arg.Any()) + .ConfigureAwait(false); + Assert.AreEqual(0, httpManager.QueueSize); + } + } + + [TestMethod] + public async Task ValidateCachedTokenAsync_OBO_WhenValidationSucceeds_CacheIsUsedAsync() + { + // Arrange + var authScheme = Substitute.For(); + authScheme.AuthorizationHeaderPrefix.Returns("CustomToken"); + authScheme.AccessTokenType.Returns("bearer"); + authScheme.KeyId.Returns("keyid"); + authScheme.GetTokenRequestParams().Returns(new Dictionary() { { "tokenParam", "tokenParamValue" } }); + + authScheme.WhenForAnyArgs(x => x.FormatResultAsync(default, default)) + .Do(x => ((AuthenticationResult)x[0]).AccessToken = "validated_" + ((AuthenticationResult)x[0]).AccessToken); + + // Validation succeeds - cached token should be used + authScheme.ValidateCachedTokenAsync(Arg.Any()) + .Returns(Task.FromResult(true)); + + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(TestConstants.AuthorityCommonTenant) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // First request - acquire initial token via OBO + httpManager.AddSuccessTokenResponseMockHandlerForPost(TestConstants.AuthorityCommonTenant); + + UserAssertion userAssertion = new UserAssertion(TestConstants.DefaultAccessToken); + var result1 = await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithAuthenticationOperation(authScheme) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.IsNotNull(result1); + Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource); + + // Second request - validation succeeds, cached token should be returned + var result2 = await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion) + .WithAuthenticationOperation(authScheme) + .ExecuteAsync() + .ConfigureAwait(false); + + Assert.AreEqual(TokenSource.Cache, result2.AuthenticationResultMetadata.TokenSource); + await authScheme.Received(1).ValidateCachedTokenAsync(Arg.Any()) + .ConfigureAwait(false); + Assert.AreEqual(0, httpManager.QueueSize); + } + } + + [TestMethod] + public async Task ValidateCachedTokenAsync_Silent_WhenValidationFails_CacheIsIgnoredAsync() + { + // Arrange + var authScheme = Substitute.For(); + authScheme.AuthorizationHeaderPrefix.Returns("CustomToken"); + authScheme.AccessTokenType.Returns("bearer"); + authScheme.GetTokenRequestParams().Returns(new Dictionary() { { "tokenParam", "tokenParamValue" } }); + + authScheme.WhenForAnyArgs(x => x.FormatResultAsync(default, default)) + .Do(x => ((AuthenticationResult)x[0]).AccessToken = "validated_" + ((AuthenticationResult)x[0]).AccessToken); + + // Validation fails - cached token should be ignored + authScheme.ValidateCachedTokenAsync(Arg.Any()) + .Returns(Task.FromResult(false)); + + using (var httpManager = new MockHttpManager()) + { + httpManager.AddInstanceDiscoveryMockHandler(); + + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(new Uri(TestConstants.AuthorityTestTenant), true) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // Populate user token cache with a valid access token and refresh token + TokenCacheHelper.PopulateCache(cca.UserTokenCacheInternal.Accessor, addSecondAt: false); + + // Silent request - validation should fail, so RT refresh should occur + httpManager.AddSuccessTokenResponseMockHandlerForPost(TestConstants.AuthorityTestTenant); + + var account = (await cca.GetAccountsAsync().ConfigureAwait(false)).Single(); + + var result = await cca.AcquireTokenSilent(TestConstants.s_scope, account) + .WithAuthenticationOperation(authScheme) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert - validation was called and failed, token was refreshed via RT + Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource); + await authScheme.Received(1).ValidateCachedTokenAsync(Arg.Any()) + .ConfigureAwait(false); + Assert.AreEqual(0, httpManager.QueueSize); + } + } + + [TestMethod] + public async Task ValidateCachedTokenAsync_Silent_WhenValidationSucceeds_CacheIsUsedAsync() + { + // Arrange + var authScheme = Substitute.For(); + authScheme.AuthorizationHeaderPrefix.Returns("CustomToken"); + authScheme.AccessTokenType.Returns("bearer"); + authScheme.GetTokenRequestParams().Returns(new Dictionary() { { "tokenParam", "tokenParamValue" } }); + + authScheme.WhenForAnyArgs(x => x.FormatResultAsync(default, default)) + .Do(x => ((AuthenticationResult)x[0]).AccessToken = "validated_" + ((AuthenticationResult)x[0]).AccessToken); + + // Validation succeeds - cached token should be used + authScheme.ValidateCachedTokenAsync(Arg.Any()) + .Returns(Task.FromResult(true)); + + using (var httpManager = new MockHttpManager()) + { + var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId) + .WithExperimentalFeatures() + .WithClientSecret(TestConstants.ClientSecret) + .WithAuthority(new Uri(TestConstants.AuthorityTestTenant), true) + .WithHttpManager(httpManager) + .BuildConcrete(); + + // Populate user token cache with a valid access token and refresh token + TokenCacheHelper.PopulateCache(cca.UserTokenCacheInternal.Accessor, addSecondAt: false); + + var account = (await cca.GetAccountsAsync().ConfigureAwait(false)).Single(); + + // Silent request - validation succeeds, cached token should be returned + var result = await cca.AcquireTokenSilent(TestConstants.s_scope, account) + .WithAuthenticationOperation(authScheme) + .ExecuteAsync() + .ConfigureAwait(false); + + // Assert - validation was called and succeeded, cached token was returned + Assert.AreEqual(TokenSource.Cache, result.AuthenticationResultMetadata.TokenSource); + await authScheme.Received(1).ValidateCachedTokenAsync(Arg.Any()) + .ConfigureAwait(false); + Assert.AreEqual(0, httpManager.QueueSize); + } + } } }