diff --git a/src/client/Microsoft.Identity.Client/AppConfig/AbstractApplicationBuilder.cs b/src/client/Microsoft.Identity.Client/AppConfig/AbstractApplicationBuilder.cs
index bd8a79f47b..40e97984f4 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/AbstractApplicationBuilder.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/AbstractApplicationBuilder.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -183,6 +183,19 @@ public T WithCacheOptions(CacheOptions options)
#if !SUPPORTS_CUSTOM_CACHE
throw new PlatformNotSupportedException("WithCacheOptions is supported only on platforms where MSAL stores tokens in memory and not on mobile platforms.");
#else
+ if (CacheOptions.IsDisabledFor(options) && options.UseSharedCache)
+ {
+ throw new MsalClientException(
+ MsalError.InvalidRequest,
+ MsalErrorMessage.InternalCacheDisabledMutuallyExclusiveMessage);
+ }
+
+ if (CacheOptions.IsDisabledFor(options) && Config.IsPublicClient)
+ {
+ throw new MsalClientException(
+ MsalError.InvalidRequest,
+ MsalErrorMessage.InternalCacheDisabledNotSupportedForPublicClient);
+ }
Config.AccessorOptions = options;
return this as T;
diff --git a/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs b/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs
index 6b5d873327..4048fa912b 100644
--- a/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs
+++ b/src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
namespace Microsoft.Identity.Client
@@ -23,6 +23,26 @@ public static CacheOptions EnableSharedCacheOptions
}
}
+ ///
+ /// Options that disable MSAL's internal in-memory token cache entirely.
+ /// Use this when your application manages its own token cache lifecycle.
+ /// When set:
+ ///
+ /// - MSAL will not read from or write to the internal cache accessor.
+ /// - Token cache serialization callbacks (OnBeforeAccess, OnAfterAccess, OnBeforeWrite) will not be invoked.
+ /// - will always return an empty collection.
+ /// - will throw a with error code .
+ /// - will throw a with error code , because the long-running OBO flow requires a cached token to refresh.
+ ///
+ ///
+ /// For confidential client flows, retrieve the refresh token using
+ ///
+ /// and call
+ /// to re-acquire a token on subsequent requests, or use another interactive flow.
+ ///
+ ///
+ public static CacheOptions DisableInternalCacheOptions => new CacheOptions { IsInternalCacheDisabled = true };
+
///
/// Constructor for the options with default values.
///
@@ -50,5 +70,23 @@ public CacheOptions(bool useSharedCache)
///
public bool UseSharedCache { get; set; }
+ ///
+ /// When set to true, MSAL will not read from or write to the internal in-memory token cache.
+ /// This is intended for advanced scenarios where the caller manages its own token cache.
+ /// Cannot be combined with .
+ ///
+ ///
+ /// When enabled,
+ /// and
+ /// will throw a with error code .
+ ///
+ public bool IsInternalCacheDisabled { get; set; }
+
+ ///
+ /// Returns true if the given instance has the internal cache disabled.
+ /// Safe to call with a null argument (returns false).
+ ///
+ internal static bool IsDisabledFor(CacheOptions options) => options?.IsInternalCacheDisabled == true;
+
}
}
diff --git a/src/client/Microsoft.Identity.Client/AuthenticationResult.cs b/src/client/Microsoft.Identity.Client/AuthenticationResult.cs
index 779de9abd9..f100b30fed 100644
--- a/src/client/Microsoft.Identity.Client/AuthenticationResult.cs
+++ b/src/client/Microsoft.Identity.Client/AuthenticationResult.cs
@@ -361,6 +361,12 @@ private AuthenticationResult(
///
public ClaimsPrincipal ClaimsPrincipal { get; set; }
+ ///
+ /// The refresh token returned in the authentication response, if any.
+ /// Access via .
+ ///
+ internal string RefreshToken { get; set; }
+
internal ApiEvent ApiEvent { get; set; }
///
diff --git a/src/client/Microsoft.Identity.Client/Cache/CacheRefreshReason.cs b/src/client/Microsoft.Identity.Client/Cache/CacheRefreshReason.cs
index d384a3c89e..02fb89c01a 100644
--- a/src/client/Microsoft.Identity.Client/Cache/CacheRefreshReason.cs
+++ b/src/client/Microsoft.Identity.Client/Cache/CacheRefreshReason.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
namespace Microsoft.Identity.Client
@@ -27,6 +27,10 @@ public enum CacheRefreshReason
///
/// When the token request goes to the identity provider because refresh_in was used and the existing token needs to be refreshed
///
- ProactivelyRefreshed = 4
+ ProactivelyRefreshed = 4,
+ ///
+ /// When the token request goes to the identity provider because the internal token cache has been disabled via .
+ ///
+ CacheDisabled = 5
}
}
diff --git a/src/client/Microsoft.Identity.Client/Cache/CacheSessionManager.cs b/src/client/Microsoft.Identity.Client/Cache/CacheSessionManager.cs
index d7c1334385..80baf8e570 100644
--- a/src/client/Microsoft.Identity.Client/Cache/CacheSessionManager.cs
+++ b/src/client/Microsoft.Identity.Client/Cache/CacheSessionManager.cs
@@ -1,10 +1,9 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
using System.Collections.Generic;
using System.Diagnostics;
-using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.Cache.Items;
@@ -42,34 +41,73 @@ public CacheSessionManager(
#region ICacheSessionManager implementation
public ITokenCacheInternal TokenCacheInternal { get; }
- public async Task FindAccessTokenAsync()
+ private bool IsInternalCacheDisabled =>
+ CacheOptions.IsDisabledFor(_requestParams.RequestContext.ServiceBundle.Config.AccessorOptions);
+
+ private bool ShouldSkipInternalCacheRead(string operationName)
+ {
+ if (!IsInternalCacheDisabled)
+ {
+ return false;
+ }
+ _requestParams.RequestContext.Logger.Verbose(
+ () => $"[Cache Session Manager] Internal cache disabled. Skipping {operationName}.");
+ return true;
+ }
+
+ private async Task RefreshCacheForReadOperationsIfEnabledAsync(string operationName)
{
+ if (ShouldSkipInternalCacheRead(operationName))
+ {
+ return;
+ }
await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
+ }
+
+ public async Task FindAccessTokenAsync()
+ {
+ await RefreshCacheForReadOperationsIfEnabledAsync("access token lookup").ConfigureAwait(false);
+ if (IsInternalCacheDisabled)
+ {
+ return null;
+ }
return await TokenCacheInternal.FindAccessTokenAsync(_requestParams).ConfigureAwait(false);
}
public async Task> SaveTokenResponseAsync(MsalTokenResponse tokenResponse)
{
var result = await TokenCacheInternal.SaveTokenResponseAsync(_requestParams, tokenResponse).ConfigureAwait(false);
- RequestContext.ApiEvent.CachedAccessTokenCount = TokenCacheInternal.Accessor.EntryCount;
+ RequestContext.ApiEvent.CachedAccessTokenCount = GetInternalCacheEntryCountForTelemetry();
return result;
}
public async Task GetAccountAssociatedWithAccessTokenAsync(MsalAccessTokenCacheItem msalAccessTokenCacheItem)
{
- await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
+ await RefreshCacheForReadOperationsIfEnabledAsync("account lookup for access token").ConfigureAwait(false);
+ if (IsInternalCacheDisabled)
+ {
+ return null;
+ }
return await TokenCacheInternal.GetAccountAssociatedWithAccessTokenAsync(_requestParams, msalAccessTokenCacheItem).ConfigureAwait(false);
}
public async Task GetIdTokenCacheItemAsync(MsalAccessTokenCacheItem accessTokenCacheItem)
{
- await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
+ await RefreshCacheForReadOperationsIfEnabledAsync("ID token lookup").ConfigureAwait(false);
+ if (IsInternalCacheDisabled)
+ {
+ return null;
+ }
return TokenCacheInternal.GetIdTokenCacheItem(accessTokenCacheItem);
}
public async Task FindFamilyRefreshTokenAsync(string familyId)
{
- await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
+ await RefreshCacheForReadOperationsIfEnabledAsync("family refresh token lookup").ConfigureAwait(false);
+ if (IsInternalCacheDisabled)
+ {
+ return null;
+ }
if (string.IsNullOrEmpty(familyId))
{
@@ -81,19 +119,31 @@ public async Task FindFamilyRefreshTokenAsync(string
public async Task FindRefreshTokenAsync()
{
- await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
+ await RefreshCacheForReadOperationsIfEnabledAsync("refresh token lookup").ConfigureAwait(false);
+ if (IsInternalCacheDisabled)
+ {
+ return null;
+ }
return await TokenCacheInternal.FindRefreshTokenAsync(_requestParams).ConfigureAwait(false);
}
public async Task IsAppFociMemberAsync(string familyId)
{
- await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
+ await RefreshCacheForReadOperationsIfEnabledAsync("FOCI membership lookup").ConfigureAwait(false);
+ if (IsInternalCacheDisabled)
+ {
+ return null;
+ }
return await TokenCacheInternal.IsFociMemberAsync(_requestParams, familyId).ConfigureAwait(false);
}
public async Task> GetAccountsAsync()
{
- await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
+ await RefreshCacheForReadOperationsIfEnabledAsync("accounts lookup").ConfigureAwait(false);
+ if (IsInternalCacheDisabled)
+ {
+ return System.Array.Empty();
+ }
return await TokenCacheInternal.GetAccountsAsync(_requestParams).ConfigureAwait(false);
}
@@ -184,7 +234,17 @@ private async Task RefreshCacheForReadOperationsAsync()
RequestContext.ApiEvent.CacheLevel = CacheLevel.L1Cache;
}
- RequestContext.ApiEvent.CachedAccessTokenCount = TokenCacheInternal.Accessor.EntryCount;
+ RequestContext.ApiEvent.CachedAccessTokenCount = GetInternalCacheEntryCountForTelemetry();
+ }
+
+ private int GetInternalCacheEntryCountForTelemetry()
+ {
+ if (IsInternalCacheDisabled)
+ {
+ return 0;
+ }
+
+ return TokenCacheInternal.Accessor.EntryCount;
}
}
}
diff --git a/src/client/Microsoft.Identity.Client/Extensibility/AuthenticationResultExtensions.cs b/src/client/Microsoft.Identity.Client/Extensibility/AuthenticationResultExtensions.cs
new file mode 100644
index 0000000000..b64220edb1
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client/Extensibility/AuthenticationResultExtensions.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+namespace Microsoft.Identity.Client.Extensibility
+{
+ ///
+ /// Extension methods for .
+ ///
+ public static class AuthenticationResultExtensions
+ {
+ ///
+ /// Returns the refresh token from the authentication result, if available.
+ /// This is intended for advanced scenarios where the caller manages its own token cache,
+ /// for example when using .
+ ///
+ /// The authentication result.
+ ///
+ /// The refresh token string associated with the result for confidential client flows, if available.
+ /// This may be a refresh token returned in the token response or a refresh token used for the
+ /// acquisition and carried on the result; null otherwise. Refresh tokens are not exposed
+ /// for public client flows, client credentials, managed identity, or when the token was
+ /// served from cache. For the normal (non-long-running) On-Behalf-Of flow, MSAL intentionally
+ /// clears the refresh token, so this method will also return null.
+ ///
+ ///
+ /// Refresh tokens are long-lived credentials. Store them securely and never expose them to end users or untrusted code.
+ ///
+ public static string GetRefreshToken(this AuthenticationResult result)
+ {
+ return result?.RefreshToken;
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
index e251af3d45..97205a9d67 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -51,6 +51,12 @@ protected override async Task ExecuteAsync(CancellationTok
}
AuthenticationResult authResult;
+ if (IsInternalCacheDisabled)
+ {
+ AuthenticationRequestParameters.RequestContext.ApiEvent.CacheInfo = CacheRefreshReason.CacheDisabled;
+ return await GetAccessTokenAsync(cancellationToken, logger).ConfigureAwait(false);
+ }
+
// Skip cache if either:
// 1) ForceRefresh is set, or
// 2) Claims are specified and there is no AccessTokenHashToRefresh.
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs
index 8f1e5dfc1c..109a27458b 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/OnBehalfOfRequest.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System.Collections.Generic;
@@ -50,6 +50,11 @@ protected override async Task ExecuteAsync(CancellationTok
CacheRefreshReason cacheInfoTelemetry = CacheRefreshReason.NotApplicable;
+ if (IsInternalCacheDisabled)
+ {
+ AuthenticationRequestParameters.RequestContext.ApiEvent.CacheInfo = CacheRefreshReason.CacheDisabled;
+ }
+
//Check if initiating a long running process
if (AuthenticationRequestParameters.ApiId == ApiEvent.ApiIds.InitiateLongRunningObo && !_onBehalfOfParameters.SearchInCacheForLongRunningObo)
{
@@ -58,6 +63,22 @@ protected override async Task ExecuteAsync(CancellationTok
return await FetchNewAccessTokenAsync(cancellationToken).ConfigureAwait(false);
}
+ if (IsInternalCacheDisabled)
+ {
+ if (_onBehalfOfParameters.UserAssertion != null)
+ {
+ return await FetchNewAccessTokenAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ // AcquireTokenInLongRunningProcess (UserAssertion == null) cannot go to the network
+ // because there is no assertion to exchange. Surface the root cause directly.
+ throw new MsalUiRequiredException(
+ MsalError.InternalCacheDisabled,
+ MsalErrorMessage.InternalCacheDisabledMessage,
+ null,
+ UiRequiredExceptionClassification.AcquireTokenSilentFailed);
+ }
+
if (!_onBehalfOfParameters.ForceRefresh && string.IsNullOrEmpty(AuthenticationRequestParameters.Claims))
{
// look for access token in the cache first.
diff --git a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs
index 5f27a8063c..cd90e65d38 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/RequestBase.cs
@@ -38,6 +38,12 @@ internal abstract class RequestBase
internal ICacheSessionManager CacheManager => AuthenticationRequestParameters.CacheSessionManager;
internal IServiceBundle ServiceBundle { get; }
+ ///
+ /// Returns true if the internal token cache is disabled via CacheOptions.DisableInternalCacheOptions.
+ ///
+ protected bool IsInternalCacheDisabled =>
+ CacheOptions.IsDisabledFor(ServiceBundle.Config.AccessorOptions);
+
protected RequestBase(
IServiceBundle serviceBundle,
AuthenticationRequestParameters authenticationRequestParameters,
@@ -349,7 +355,7 @@ protected async Task CacheTokenResponseAndCreateAuthentica
#if !MOBILE
atItem?.AddAdditionalCacheParameters(clientInfoFromServer?.AdditionalResponseParameters);
#endif
- return await AuthenticationResult.CreateAsync(
+ var authResult = await AuthenticationResult.CreateAsync(
atItem,
idtItem,
AuthenticationRequestParameters.AuthenticationScheme,
@@ -360,6 +366,11 @@ protected async Task CacheTokenResponseAndCreateAuthentica
msalTokenResponse.SpaAuthCode,
msalTokenResponse.CreateExtensionDataStringMap(),
cancellationToken).ConfigureAwait(false);
+
+ authResult.RefreshToken = AuthenticationRequestParameters.AppConfig.IsConfidentialClient
+ ? msalTokenResponse.RefreshToken
+ : null;
+ return authResult;
}
protected virtual void ValidateAccountIdentifiers(ClientInfo fromServer)
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 9d85da5b17..fbc46acf24 100644
--- a/src/client/Microsoft.Identity.Client/Internal/Requests/Silent/CacheSilentStrategy.cs
+++ b/src/client/Microsoft.Identity.Client/Internal/Requests/Silent/CacheSilentStrategy.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -42,6 +42,16 @@ public async Task ExecuteAsync(CancellationToken cancellat
ThrowIfCurrentBrokerAccount();
+ if (CacheOptions.IsDisabledFor(ServiceBundle.Config.AccessorOptions))
+ {
+ AuthenticationRequestParameters.RequestContext.ApiEvent.CacheInfo = CacheRefreshReason.CacheDisabled;
+ throw new MsalUiRequiredException(
+ MsalError.InternalCacheDisabled,
+ MsalErrorMessage.InternalCacheDisabledMessage,
+ null,
+ UiRequiredExceptionClassification.AcquireTokenSilentFailed);
+ }
+
AuthenticationResult authResult = null;
if (!_silentParameters.ForceRefresh && string.IsNullOrEmpty(AuthenticationRequestParameters.Claims))
diff --git a/src/client/Microsoft.Identity.Client/MsalError.cs b/src/client/Microsoft.Identity.Client/MsalError.cs
index 8d7faf1044..eb26f51c32 100644
--- a/src/client/Microsoft.Identity.Client/MsalError.cs
+++ b/src/client/Microsoft.Identity.Client/MsalError.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -1266,5 +1266,17 @@ public static class MsalError
/// when mTLS Proof-of-Possession is required.
///
public const string InvalidCredentialMaterial = "invalid_credential_material";
+
+ ///
+ /// What happened? A cache-dependent API was called, but MSAL's internal token cache is disabled via
+ /// . This can occur with APIs such as
+ ///
+ /// and AcquireTokenInLongRunningProcess(...).
+ /// Mitigation Use an authentication flow that does not depend on MSAL's internal cache, such as
+ ///
+ /// with the refresh token obtained from ,
+ /// or use another interactive flow, as appropriate for your application.
+ ///
+ public const string InternalCacheDisabled = "internal_cache_disabled";
}
}
diff --git a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
index 85039fdf4e..255b7e6c17 100644
--- a/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
+++ b/src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System.Globalization;
@@ -460,5 +460,18 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName)
"configured via WithCertificate(). Non-certificate credentials (client secrets, static signed " +
"assertions, and string-returning assertion delegates) are not supported. " +
"Remove SendCertificateOverMtls or switch to a certificate credential.";
+
+ public const string InternalCacheDisabledMessage =
+ "Silent token acquisition is not supported when the internal cache is disabled via CacheOptions.DisableInternalCacheOptions. " +
+ "For confidential client flows, retrieve the refresh token using AuthenticationResultExtensions.GetRefreshToken() and call AcquireTokenByRefreshToken, " +
+ "or use another interactive flow.";
+
+ public const string InternalCacheDisabledMutuallyExclusiveMessage =
+ "CacheOptions.IsInternalCacheDisabled and CacheOptions.UseSharedCache are mutually exclusive. " +
+ "Set only one of these options.";
+
+ public const string InternalCacheDisabledNotSupportedForPublicClient =
+ "CacheOptions.DisableInternalCacheOptions is not supported for public client applications. " +
+ "This option is intended for confidential client flows where the application manages its own token cache.";
}
}
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 054ba42814..855c6d01c3 100644
--- a/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt
+++ b/src/client/Microsoft.Identity.Client/PublicApi/net462/PublicAPI.Unshipped.txt
@@ -5,3 +5,10 @@ Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.get -> bool
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.set -> void
+Microsoft.Identity.Client.CacheRefreshReason.CacheDisabled = 5 -> Microsoft.Identity.Client.CacheRefreshReason
+Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions
+const Microsoft.Identity.Client.MsalError.InternalCacheDisabled = "internal_cache_disabled" -> string
+static Microsoft.Identity.Client.CacheOptions.DisableInternalCacheOptions.get -> Microsoft.Identity.Client.CacheOptions
+static Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions.GetRefreshToken(this Microsoft.Identity.Client.AuthenticationResult result) -> string
\ 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 054ba42814..855c6d01c3 100644
--- a/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt
+++ b/src/client/Microsoft.Identity.Client/PublicApi/net472/PublicAPI.Unshipped.txt
@@ -5,3 +5,10 @@ Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.get -> bool
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.set -> void
+Microsoft.Identity.Client.CacheRefreshReason.CacheDisabled = 5 -> Microsoft.Identity.Client.CacheRefreshReason
+Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions
+const Microsoft.Identity.Client.MsalError.InternalCacheDisabled = "internal_cache_disabled" -> string
+static Microsoft.Identity.Client.CacheOptions.DisableInternalCacheOptions.get -> Microsoft.Identity.Client.CacheOptions
+static Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions.GetRefreshToken(this Microsoft.Identity.Client.AuthenticationResult result) -> string
\ No newline at end of file
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 054ba42814..855c6d01c3 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
@@ -5,3 +5,10 @@ Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.get -> bool
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.set -> void
+Microsoft.Identity.Client.CacheRefreshReason.CacheDisabled = 5 -> Microsoft.Identity.Client.CacheRefreshReason
+Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions
+const Microsoft.Identity.Client.MsalError.InternalCacheDisabled = "internal_cache_disabled" -> string
+static Microsoft.Identity.Client.CacheOptions.DisableInternalCacheOptions.get -> Microsoft.Identity.Client.CacheOptions
+static Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions.GetRefreshToken(this Microsoft.Identity.Client.AuthenticationResult result) -> string
\ No newline at end of file
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 054ba42814..855c6d01c3 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
@@ -5,3 +5,10 @@ Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.get -> bool
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.set -> void
+Microsoft.Identity.Client.CacheRefreshReason.CacheDisabled = 5 -> Microsoft.Identity.Client.CacheRefreshReason
+Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions
+const Microsoft.Identity.Client.MsalError.InternalCacheDisabled = "internal_cache_disabled" -> string
+static Microsoft.Identity.Client.CacheOptions.DisableInternalCacheOptions.get -> Microsoft.Identity.Client.CacheOptions
+static Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions.GetRefreshToken(this Microsoft.Identity.Client.AuthenticationResult result) -> string
\ No newline at end of file
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 054ba42814..855c6d01c3 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
@@ -5,3 +5,10 @@ Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.get -> bool
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.set -> void
+Microsoft.Identity.Client.CacheRefreshReason.CacheDisabled = 5 -> Microsoft.Identity.Client.CacheRefreshReason
+Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions
+const Microsoft.Identity.Client.MsalError.InternalCacheDisabled = "internal_cache_disabled" -> string
+static Microsoft.Identity.Client.CacheOptions.DisableInternalCacheOptions.get -> Microsoft.Identity.Client.CacheOptions
+static Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions.GetRefreshToken(this Microsoft.Identity.Client.AuthenticationResult result) -> string
\ No newline at end of file
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 054ba42814..855c6d01c3 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
@@ -5,3 +5,10 @@ Microsoft.Identity.Client.AssertionRequestOptions.CorrelationId.set -> void
const Microsoft.Identity.Client.MsalError.InvalidCredentialMaterial = "invalid_credential_material" -> string
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.get -> bool
Microsoft.Identity.Client.AppConfig.CertificateOptions.SendCertificateOverMtls.init -> void
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.get -> bool
+Microsoft.Identity.Client.CacheOptions.IsInternalCacheDisabled.set -> void
+Microsoft.Identity.Client.CacheRefreshReason.CacheDisabled = 5 -> Microsoft.Identity.Client.CacheRefreshReason
+Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions
+const Microsoft.Identity.Client.MsalError.InternalCacheDisabled = "internal_cache_disabled" -> string
+static Microsoft.Identity.Client.CacheOptions.DisableInternalCacheOptions.get -> Microsoft.Identity.Client.CacheOptions
+static Microsoft.Identity.Client.Extensibility.AuthenticationResultExtensions.GetRefreshToken(this Microsoft.Identity.Client.AuthenticationResult result) -> string
\ No newline at end of file
diff --git a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs
index f0fefb153f..c3ae093365 100644
--- a/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs
+++ b/src/client/Microsoft.Identity.Client/TokenCache.ITokenCacheInternal.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -142,15 +142,26 @@ async Task> IToke
tenantId,
wamAccountIds);
- // Add the newly obtained id token to the list of profiles
+ // Add the newly obtained id token to the list of profiles.
IDictionary tenantProfiles = null;
if (msalIdTokenCacheItem.TenantId != null)
{
- tenantProfiles = await GetTenantProfilesAsync(requestParams, homeAccountId).ConfigureAwait(false);
- if (tenantProfiles != null)
+ if (CacheOptions.IsDisabledFor(ServiceBundle.Config.AccessorOptions))
{
- TenantProfile tenantProfile = new TenantProfile(msalIdTokenCacheItem);
- tenantProfiles[msalIdTokenCacheItem.TenantId] = tenantProfile;
+ // When the internal cache is disabled, skip GetTenantProfilesAsync (which reads
+ // from Accessor) and instead seed a fresh dict with just the current profile.
+ tenantProfiles = new Dictionary
+ {
+ [msalIdTokenCacheItem.TenantId] = new TenantProfile(msalIdTokenCacheItem)
+ };
+ }
+ else
+ {
+ tenantProfiles = await GetTenantProfilesAsync(requestParams, homeAccountId).ConfigureAwait(false);
+ if (tenantProfiles != null)
+ {
+ tenantProfiles[msalIdTokenCacheItem.TenantId] = new TenantProfile(msalIdTokenCacheItem);
+ }
}
}
@@ -165,6 +176,12 @@ async Task> IToke
#endregion
+ if (CacheOptions.IsDisabledFor(ServiceBundle.Config.AccessorOptions))
+ {
+ logger.Verbose(() => "[SaveTokenResponseAsync] Internal cache is disabled (CacheOptions.DisableInternalCacheOptions). Skipping all cache writes.");
+ return Tuple.Create(msalAccessTokenCacheItem, msalIdTokenCacheItem, account);
+ }
+
logger.Verbose(() => $"[SaveTokenResponseAsync] Entering token cache semaphore. Count {_semaphoreSlim.GetCurrentCountLogMessage()}.");
await _semaphoreSlim.WaitAsync(requestParams.RequestContext.UserCancellationToken).ConfigureAwait(false);
logger.Verbose(() => "[SaveTokenResponseAsync] Entered token cache semaphore. ");
diff --git a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/SilentAuthTests.cs b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/SilentAuthTests.cs
index 0acea2b534..67b827867e 100644
--- a/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/SilentAuthTests.cs
+++ b/tests/Microsoft.Identity.Test.Integration.netcore/HeadlessTests/SilentAuthTests.cs
@@ -13,6 +13,7 @@
using System.Threading.Tasks;
using Microsoft.Identity.Test.Common.Core.Helpers;
+
namespace Microsoft.Identity.Test.Integration.HeadlessTests
{
[TestClass]
@@ -27,6 +28,12 @@ public class SilentAuthTests
private IPublicClientApplication pca = null;
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ ApplicationBase.ResetStateForTest();
+ }
+
[TestMethod]
public async Task SilentAuth_ForceRefresh_Async()
{
diff --git a/tests/Microsoft.Identity.Test.Unit/CacheTests/InternalCacheOptionsTests.cs b/tests/Microsoft.Identity.Test.Unit/CacheTests/InternalCacheOptionsTests.cs
index 333255bedd..10ebbe18e5 100644
--- a/tests/Microsoft.Identity.Test.Unit/CacheTests/InternalCacheOptionsTests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/CacheTests/InternalCacheOptionsTests.cs
@@ -1,10 +1,12 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
using System.Linq;
+using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Identity.Client;
+using Microsoft.Identity.Client.Extensibility;
using Microsoft.Identity.Test.Common.Core.Helpers;
using Microsoft.Identity.Test.Common.Core.Mocks;
using Microsoft.Identity.Test.Common.Mocks;
@@ -188,5 +190,383 @@ private async Task ClientCredsAcquireAndAssertTokenSourceA
return result;
}
+
+ /// Static helper and the bool property have correct values.
+ [TestMethod]
+ public void DisableInternalCacheOptions_StaticProperty_HasCorrectValues()
+ {
+ var disabled = CacheOptions.DisableInternalCacheOptions;
+ Assert.IsNotNull(disabled);
+ Assert.IsTrue(disabled.IsInternalCacheDisabled, "DisableInternalCacheOptions.IsInternalCacheDisabled should be true");
+ Assert.IsFalse(disabled.UseSharedCache, "DisableInternalCacheOptions.UseSharedCache should be false");
+
+ var defaults = new CacheOptions();
+ Assert.IsFalse(defaults.IsInternalCacheDisabled, "Default CacheOptions should have IsInternalCacheDisabled == false");
+ }
+
+ /// CacheOptions.IsDisabledFor is null-safe and returns the correct value for all inputs.
+ [TestMethod]
+ public void CacheOptions_IsDisabledFor_NullSafeAndCorrect()
+ {
+ Assert.IsFalse(CacheOptions.IsDisabledFor(null),
+ "IsDisabledFor(null) should return false — null options means cache is not disabled.");
+ Assert.IsFalse(CacheOptions.IsDisabledFor(new CacheOptions()),
+ "IsDisabledFor(default CacheOptions) should return false.");
+ Assert.IsTrue(CacheOptions.IsDisabledFor(CacheOptions.DisableInternalCacheOptions),
+ "IsDisabledFor(DisableInternalCacheOptions) should return true.");
+ }
+
+ /// GetRefreshToken() extension returns the refresh token from a real token flow.
+ [TestMethod]
+ public async Task GetRefreshToken_AcquireTokenByAuthCode_ReturnsToken_Async()
+ {
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(TestConstants.AuthorityCommonTenant);
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithRedirectUri(TestConstants.RedirectUri)
+ .WithHttpManager(harness.HttpManager)
+ .BuildConcrete();
+
+ AuthenticationResult result = await app
+ .AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ string rt = result.GetRefreshToken();
+ Assert.IsNotNull(rt, "GetRefreshToken() should return a non-null refresh token.");
+ Assert.AreEqual(TestConstants.RTSecret, rt, "GetRefreshToken() should return the refresh token from the token response.");
+ }
+ }
+
+ /// GetRefreshToken() returns null for public client applications — RT exposure is confidential client only.
+ [TestMethod]
+ public async Task GetRefreshToken_PublicClient_ReturnsNull_Async()
+ {
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(TestConstants.AuthorityCommonTenant);
+
+ var pca = PublicClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithAuthority(new Uri(ClientApplicationBase.DefaultAuthority), true)
+ .WithHttpManager(harness.HttpManager)
+ .BuildConcrete();
+
+ pca.ServiceBundle.ConfigureMockWebUI();
+
+ AuthenticationResult result = await pca
+ .AcquireTokenInteractive(TestConstants.s_scope)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ Assert.IsNull(result.GetRefreshToken(), "GetRefreshToken() must return null for public client applications.");
+ }
+ }
+
+ /// When DisableInternalCacheOptions is set, AcquireTokenForClient always hits the network and nothing is stored.
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_AcquireTokenForClient_NeverCaches_Async()
+ {
+ using (var httpManager = new MockHttpManager())
+ {
+ httpManager.AddInstanceDiscoveryMockHandler();
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithHttpManager(httpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ // Two separate network calls expected because the cache is disabled.
+ httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+ var result1 = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithTenantId(TestConstants.Utid)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ httpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+ var result2 = await app.AcquireTokenForClient(TestConstants.s_scope)
+ .WithTenantId(TestConstants.Utid)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource,
+ "First call should come from the network, not the cache.");
+ Assert.AreEqual(CacheRefreshReason.CacheDisabled, result1.AuthenticationResultMetadata.CacheRefreshReason,
+ "CacheRefreshReason should be CacheDisabled when the internal cache is disabled.");
+ Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource,
+ "Second call should also come from the network because the internal cache is disabled.");
+ Assert.AreEqual(CacheRefreshReason.CacheDisabled, result2.AuthenticationResultMetadata.CacheRefreshReason,
+ "CacheRefreshReason should be CacheDisabled when the internal cache is disabled.");
+
+ // Client credentials does not include a refresh token in the token response.
+ Assert.IsNull(result1.GetRefreshToken(),
+ "GetRefreshToken() must return null for AcquireTokenForClient — the server does not issue refresh tokens for client credentials.");
+
+ Assert.IsEmpty(app.AppTokenCacheInternal.Accessor.GetAllAccessTokens(),
+ "No access tokens should have been stored in the internal cache.");
+ }
+ }
+
+ /// DisableInternalCacheOptions also skips the user token cache.
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_AcquireTokenByAuthCode_DoesNotCacheTokens_Async()
+ {
+ using (var harness = CreateTestHarness())
+ {
+ harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost(TestConstants.AuthorityCommonTenant);
+
+ var app = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithRedirectUri(TestConstants.RedirectUri)
+ .WithHttpManager(harness.HttpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ await app
+ .AcquireTokenByAuthorizationCode(TestConstants.s_scope, TestConstants.DefaultAuthorizationCode)
+ .ExecuteAsync()
+ .ConfigureAwait(false);
+
+ Assert.IsEmpty(app.UserTokenCacheInternal.Accessor.GetAllAccessTokens(),
+ "No access tokens should be stored when the internal cache is disabled.");
+ Assert.IsEmpty(app.UserTokenCacheInternal.Accessor.GetAllRefreshTokens(),
+ "No refresh tokens should be stored when the internal cache is disabled.");
+ Assert.IsEmpty(app.UserTokenCacheInternal.Accessor.GetAllIdTokens(),
+ "No ID tokens should be stored when the internal cache is disabled.");
+ }
+ }
+
+ /// DisableInternalCacheOptions throws at configuration time for public client applications — the feature is confidential client only.
+ [TestMethod]
+ public void DisableInternalCacheOptions_PublicClient_ThrowsOnWithCacheOptions()
+ {
+ var ex = AssertException.Throws(
+ () => PublicClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .Build());
+
+ Assert.AreEqual(MsalError.InvalidRequest, ex.ErrorCode,
+ "The error code should be InvalidRequest.");
+ StringAssert.Contains(ex.Message, "public client",
+ "The error message should explain that this option is not supported for public clients.");
+ }
+
+ /// Mutual exclusivity: IsInternalCacheDisabled and UseSharedCache cannot both be set.
+ [TestMethod]
+ public void DisableInternalCacheOptions_AndUseSharedCache_ThrowsOnBuild()
+ {
+ var conflictingOptions = new CacheOptions
+ {
+ IsInternalCacheDisabled = true,
+ UseSharedCache = true
+ };
+
+ var ex = AssertException.Throws(
+ () => ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithCacheOptions(conflictingOptions)
+ .Build());
+
+ Assert.AreEqual(MsalError.InvalidRequest, ex.ErrorCode,
+ "Setting both IsInternalCacheDisabled and UseSharedCache should throw an InvalidRequest error.");
+ }
+
+ ///
+ /// Short-running OBO with DisableInternalCacheOptions: every call always goes to the network
+ /// and nothing is written to the internal cache.
+ ///
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_ShortRunningObo_AlwaysHitsNetwork_Async()
+ {
+ using (var httpManager = new MockHttpManager())
+ {
+ httpManager.AddInstanceDiscoveryMockHandler();
+
+ var cca = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(httpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ var userAssertion = new UserAssertion(TestConstants.DefaultAccessToken);
+
+ // First OBO call — must hit the network.
+ httpManager.AddMockHandler(new MockHttpMessageHandler
+ {
+ ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token",
+ ExpectedMethod = HttpMethod.Post,
+ ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage()
+ });
+
+ var result1 = await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.IdentityProvider, result1.AuthenticationResultMetadata.TokenSource,
+ "First OBO call should hit the network.");
+ Assert.AreEqual(CacheRefreshReason.CacheDisabled, result1.AuthenticationResultMetadata.CacheRefreshReason,
+ "CacheRefreshReason should be CacheDisabled for OBO when the internal cache is disabled.");
+ Assert.IsNull(result1.GetRefreshToken(),
+ "Normal OBO does not expose a refresh token (MSAL intentionally clears it).");
+
+ // Second OBO call with the same assertion — must hit the network again because the cache is disabled.
+ httpManager.AddMockHandler(new MockHttpMessageHandler
+ {
+ ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token",
+ ExpectedMethod = HttpMethod.Post,
+ ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage()
+ });
+
+ var result2 = await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.IdentityProvider, result2.AuthenticationResultMetadata.TokenSource,
+ "Second OBO call should also hit the network because the internal cache is disabled.");
+ Assert.AreEqual(CacheRefreshReason.CacheDisabled, result2.AuthenticationResultMetadata.CacheRefreshReason,
+ "CacheRefreshReason should be CacheDisabled for OBO when the internal cache is disabled.");
+
+ Assert.IsEmpty(cca.UserTokenCacheInternal.Accessor.GetAllAccessTokens(),
+ "No access tokens should have been stored in the internal cache.");
+ Assert.IsEmpty(cca.UserTokenCacheInternal.Accessor.GetAllRefreshTokens(),
+ "No refresh tokens should have been stored in the internal cache.");
+ }
+ }
+
+ ///
+ /// When DisableInternalCacheOptions is set, Account.TenantProfiles should still contain
+ /// the current tenant's profile derived from the freshly received ID token (no cache reads needed).
+ ///
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_OboResult_HasTenantProfile_Async()
+ {
+ using (var httpManager = new MockHttpManager())
+ {
+ httpManager.AddInstanceDiscoveryMockHandler();
+
+ var cca = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(httpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ var userAssertion = new UserAssertion(TestConstants.DefaultAccessToken);
+
+ httpManager.AddMockHandler(new MockHttpMessageHandler
+ {
+ ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token",
+ ExpectedMethod = HttpMethod.Post,
+ ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage()
+ });
+
+ var result = await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, userAssertion)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ var profiles = result.Account.GetTenantProfiles()?.ToList();
+ Assert.IsNotNull(profiles, "TenantProfiles should not be null when DisableInternalCacheOptions is set.");
+ Assert.HasCount(1, profiles, "Should have exactly one TenantProfile from the freshly received ID token.");
+ Assert.AreEqual(TestConstants.Utid, profiles[0].TenantId,
+ "TenantProfile.TenantId should match the ID token's tenant.");
+ }
+ }
+
+ ///
+ /// Long-running OBO with DisableInternalCacheOptions: InitiateLongRunningProcessInWebApi always hits
+ /// the network and stores nothing. AcquireTokenInLongRunningProcess cannot go to the network
+ /// (no user assertion to exchange) and throws MsalUiRequiredException with error code
+ /// MsalError.InternalCacheDisabled to surface the root cause directly.
+ ///
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_LongRunningObo_InitiateAlwaysHitsNetwork_AcquireThrows_Async()
+ {
+ using (var httpManager = new MockHttpManager())
+ {
+ httpManager.AddInstanceDiscoveryMockHandler();
+
+ var cca = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(httpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ string oboCacheKey = "obo-cache-key";
+
+ // Initiate — must hit the network.
+ httpManager.AddMockHandler(new MockHttpMessageHandler
+ {
+ ExpectedUrl = TestConstants.AuthorityCommonTenant + "oauth2/v2.0/token",
+ ExpectedMethod = HttpMethod.Post,
+ ResponseMessage = MockHelpers.CreateSuccessTokenResponseMessage()
+ });
+
+ var result = await cca
+ .InitiateLongRunningProcessInWebApi(TestConstants.s_scope, TestConstants.DefaultAccessToken, ref oboCacheKey)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.AreEqual(TokenSource.IdentityProvider, result.AuthenticationResultMetadata.TokenSource,
+ "Initiate should always hit the network when the internal cache is disabled.");
+ Assert.AreEqual(CacheRefreshReason.CacheDisabled, result.AuthenticationResultMetadata.CacheRefreshReason,
+ "CacheRefreshReason should be CacheDisabled for long-running OBO when the internal cache is disabled.");
+
+ Assert.IsEmpty(cca.UserTokenCacheInternal.Accessor.GetAllAccessTokens(),
+ "No access tokens should have been stored in the internal cache.");
+ Assert.IsEmpty(cca.UserTokenCacheInternal.Accessor.GetAllRefreshTokens(),
+ "No refresh tokens should have been stored in the internal cache.");
+
+ // AcquireTokenInLongRunningProcess cannot go to the network (no user assertion to
+ // exchange). When the cache is disabled it surfaces the root cause directly.
+ var ex = await AssertException.TaskThrowsAsync(
+ () => cca.AcquireTokenInLongRunningProcess(TestConstants.s_scope, oboCacheKey).ExecuteAsync())
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(MsalError.InternalCacheDisabled, ex.ErrorCode,
+ "AcquireTokenInLongRunningProcess should throw MsalError.InternalCacheDisabled when the cache is disabled.");
+ Assert.AreEqual(UiRequiredExceptionClassification.AcquireTokenSilentFailed, ex.Classification,
+ "Classification should signal that silent auth failed.");
+ }
+ }
+
+ ///
+ /// AcquireTokenSilent on a CCA (confidential client) throws the same MsalUiRequiredException
+ /// as on a PCA when DisableInternalCacheOptions is set.
+ ///
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_AcquireTokenSilent_CcaVariant_ThrowsWithCorrectError_Async()
+ {
+ var cca = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .Build();
+
+ var account = new Account("aid.tid", "user@contoso.com", "login.microsoftonline.com");
+
+ var ex = await AssertException.TaskThrowsAsync(
+ () => cca.AcquireTokenSilent(TestConstants.s_scope, account).ExecuteAsync())
+ .ConfigureAwait(false);
+
+ Assert.AreEqual(MsalError.InternalCacheDisabled, ex.ErrorCode,
+ "The error code should identify that the internal cache is disabled.");
+ StringAssert.Contains(ex.Message, "AcquireTokenByRefreshToken",
+ "The error message should guide the caller towards AcquireTokenByRefreshToken.");
+ Assert.AreEqual(UiRequiredExceptionClassification.AcquireTokenSilentFailed, ex.Classification,
+ "Classification should signal that silent auth failed.");
+ }
}
}
diff --git a/tests/Microsoft.Identity.Test.Unit/TelemetryTests/HttpTelemetryTests.cs b/tests/Microsoft.Identity.Test.Unit/TelemetryTests/HttpTelemetryTests.cs
index ef45593945..e2637d0f88 100644
--- a/tests/Microsoft.Identity.Test.Unit/TelemetryTests/HttpTelemetryTests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/TelemetryTests/HttpTelemetryTests.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -530,6 +530,73 @@ await cca.AcquireTokenForClient(TestConstants.s_scope)
}
}
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_AcquireTokenForClient_EmitsCacheDisabledTelemetry_Async()
+ {
+ using (_harness = CreateTestHarness())
+ {
+ _harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ var requestHandler = _harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(_harness.HttpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ await cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ AssertCurrentTelemetry(requestHandler.ActualRequestMessage, ApiIds.AcquireTokenForClient, CacheRefreshReason.CacheDisabled);
+ }
+ }
+
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_AcquireTokenOnBehalfOf_EmitsCacheDisabledTelemetry_Async()
+ {
+ using (_harness = CreateTestHarness())
+ {
+ _harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ var requestHandler = _harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost();
+
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(_harness.HttpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ await cca.AcquireTokenOnBehalfOf(TestConstants.s_scope, new UserAssertion(TestConstants.DefaultAccessToken))
+ .ExecuteAsync().ConfigureAwait(false);
+
+ AssertCurrentTelemetry(requestHandler.ActualRequestMessage, ApiIds.AcquireTokenOnBehalfOf, CacheRefreshReason.CacheDisabled);
+ }
+ }
+
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_InitiateLongRunningObo_EmitsCacheDisabledTelemetry_Async()
+ {
+ using (_harness = CreateTestHarness())
+ {
+ _harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ var requestHandler = _harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost();
+
+ var cca = ConfidentialClientApplicationBuilder.Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(_harness.HttpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ var cacheKey = string.Empty;
+ await cca.InitiateLongRunningProcessInWebApi(TestConstants.s_scope, TestConstants.DefaultAccessToken, ref cacheKey)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ AssertCurrentTelemetry(requestHandler.ActualRequestMessage, ApiIds.InitiateLongRunningObo, CacheRefreshReason.CacheDisabled);
+ }
+ }
+
private PublicClientApplication CreatePublicClientApp(bool isLegacyCacheEnabled = true)
{
return PublicClientApplicationBuilder.Create(TestConstants.ClientId)
diff --git a/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs b/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs
index f074df9f80..8e987727f4 100644
--- a/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs
+++ b/tests/Microsoft.Identity.Test.Unit/TelemetryTests/OTelInstrumentationTests.cs
@@ -1,4 +1,4 @@
-// Copyright (c) Microsoft Corporation. All rights reserved.
+// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
using System;
@@ -404,15 +404,95 @@ private async Task AcquireTokenMsalClientExceptionAsync()
Assert.IsNotNull(exClient.ErrorCode);
}
- private void CreateApplication()
+ private void CreateApplication(CacheOptions cacheOptions = null)
{
- _cca = ConfidentialClientApplicationBuilder
+ var builder = ConfidentialClientApplicationBuilder
.Create(TestConstants.ClientId)
.WithExperimentalFeatures()
.WithAuthority(TestConstants.AuthorityUtidTenant)
.WithClientSecret(TestConstants.ClientSecret)
- .WithHttpManager(_harness.HttpManager)
- .BuildConcrete();
+ .WithHttpManager(_harness.HttpManager);
+
+ if (cacheOptions != null)
+ {
+ builder = builder.WithCacheOptions(cacheOptions);
+ }
+
+ _cca = builder.BuildConcrete();
+ }
+
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_AcquireTokenForClient_OTelEmitsCacheDisabledReason_Async()
+ {
+ using (_harness = CreateTestHarness())
+ {
+ CreateApplication(cacheOptions: CacheOptions.DisableInternalCacheOptions);
+
+ _harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ _harness.HttpManager.AddMockHandlerSuccessfulClientCredentialTokenResponseMessage();
+
+ AuthenticationResult result = await _cca.AcquireTokenForClient(TestConstants.s_scope)
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(CacheRefreshReason.CacheDisabled, result.AuthenticationResultMetadata.CacheRefreshReason);
+
+ s_meterProvider.ForceFlush();
+
+ var msalSuccess = _exportedMetrics.FirstOrDefault(m => m.Name == "MsalSuccess");
+ Assert.IsNotNull(msalSuccess, "MsalSuccess metric should be emitted.");
+
+ foreach (var metricPoint in msalSuccess.GetMetricPoints())
+ {
+ AssertTagValue(metricPoint.Tags, TelemetryConstants.CacheRefreshReason, CacheRefreshReason.CacheDisabled);
+ }
+ }
+ }
+
+ [TestMethod]
+ public async Task DisableInternalCacheOptions_AcquireTokenOnBehalfOf_OTelEmitsCacheDisabledReason_Async()
+ {
+ using (_harness = CreateTestHarness())
+ {
+ _harness.HttpManager.AddInstanceDiscoveryMockHandler();
+ _harness.HttpManager.AddSuccessTokenResponseMockHandlerForPost();
+
+ var cca = ConfidentialClientApplicationBuilder
+ .Create(TestConstants.ClientId)
+ .WithClientSecret(TestConstants.ClientSecret)
+ .WithAuthority(TestConstants.AuthorityCommonTenant)
+ .WithHttpManager(_harness.HttpManager)
+ .WithCacheOptions(CacheOptions.DisableInternalCacheOptions)
+ .BuildConcrete();
+
+ AuthenticationResult result = await cca.AcquireTokenOnBehalfOf(
+ TestConstants.s_scope, new UserAssertion(TestConstants.DefaultAccessToken))
+ .ExecuteAsync().ConfigureAwait(false);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(CacheRefreshReason.CacheDisabled, result.AuthenticationResultMetadata.CacheRefreshReason);
+
+ s_meterProvider.ForceFlush();
+
+ var msalSuccess = _exportedMetrics.FirstOrDefault(m => m.Name == "MsalSuccess");
+ Assert.IsNotNull(msalSuccess, "MsalSuccess metric should be emitted.");
+
+ foreach (var metricPoint in msalSuccess.GetMetricPoints())
+ {
+ AssertTagValue(metricPoint.Tags, TelemetryConstants.CacheRefreshReason, CacheRefreshReason.CacheDisabled);
+ }
+ }
+ }
+
+ private void AssertTagValue(ReadOnlyTagCollection tags, string tagKey, object expectedValue)
+ {
+ IDictionary tagDictionary = new Dictionary();
+ foreach (var tag in tags)
+ {
+ tagDictionary[tag.Key] = tag.Value;
+ }
+ Assert.IsTrue(tagDictionary.ContainsKey(tagKey), $"Tag '{tagKey}' is missing from metric point.");
+ Assert.AreEqual(expectedValue, tagDictionary[tagKey], $"Tag '{tagKey}' has unexpected value.");
}
[TestMethod]