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]