Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cc2ce8b
feat: expose refresh token via extension and add CacheOptions.Disable…
Robbie-Microsoft Apr 23, 2026
f27c85b
style: remove AC# labels and section comment from test summaries
Robbie-Microsoft Apr 23, 2026
19c0bf1
Address Copilot review: guard all cache read methods, use Verbose log…
Robbie-Microsoft Apr 23, 2026
3e63f55
Address Bogdan and Gladwin review comments
Robbie-Microsoft Apr 24, 2026
d0a89b3
refactor: address Copilot review feedback on PR #5947
Robbie-Microsoft Apr 24, 2026
59bc826
feat: gate RefreshToken exposure on confidential client only
Robbie-Microsoft Apr 24, 2026
8eaf5c7
test: add GetRefreshToken_PublicClient_ReturnsNull to confirm RT is n…
Robbie-Microsoft Apr 24, 2026
9b43d4c
refactor: address Copilot comments - centralize error message and use…
Robbie-Microsoft Apr 24, 2026
daed48d
Add code review comment on redundant cache-disabled check pattern
gladjohn Apr 24, 2026
4a22470
fix: throw MsalUiRequiredException instead of MsalClientException for…
Robbie-Microsoft Apr 24, 2026
d5d3fb2
Revert "Add code review comment on redundant cache-disabled check pat…
Robbie-Microsoft Apr 24, 2026
0b417c3
docs: add method signature to GetRefreshToken cref for reliable doc r…
Robbie-Microsoft Apr 24, 2026
3b6ba8e
docs: clarify GetRefreshToken guidance applies to confidential client…
Robbie-Microsoft Apr 24, 2026
d5eafc3
Fix OBO integration test: assert null RT for normal OBO flow
Robbie-Microsoft Apr 28, 2026
daa4755
Rename OBO test to match its null-RT assertions
Robbie-Microsoft Apr 28, 2026
ca67dda
Potential fix for pull request finding
Robbie-Microsoft Apr 28, 2026
a4d1f30
docs: expand CacheOptions.DisableInternalCache XML docs to mention ca…
Copilot Apr 28, 2026
5daefd4
Improve DisableInternalCache docs, fix GetTenantProfilesAsync guard, …
Robbie-Microsoft Apr 28, 2026
a696c93
test: add OBO + CCA unit tests for DisableInternalCache; remove integ…
Robbie-Microsoft Apr 29, 2026
fc0e00d
feat: add CacheRefreshReason.CacheDisabled telemetry for DisableInter…
Robbie-Microsoft Apr 29, 2026
3316031
fix: address Copilot review comments
Robbie-Microsoft Apr 29, 2026
d9953c9
fix: remove unused usings and orphaned KV fields from SilentAuthTests…
Robbie-Microsoft Apr 29, 2026
c6f8f14
Add telemetry tests for CacheRefreshReason.CacheDisabled
Robbie-Microsoft Apr 29, 2026
4fde7b3
Fix Copilot comments: remove unused using, short-circuit EntryCount w…
Robbie-Microsoft Apr 29, 2026
3b711cd
Address Neha's feedback: restore comment, consolidate OBO telemetry, …
Robbie-Microsoft Apr 29, 2026
efab63c
Populate TenantProfiles from ID token when internal cache is disabled
Robbie-Microsoft Apr 29, 2026
505f7f4
Rename useCacheOptions parameter to cacheOptions in OTelInstrumentati…
Robbie-Microsoft Apr 29, 2026
fc32179
Refactor: IsInternalCacheDisabled helper on RequestBase; doc + test i…
Robbie-Microsoft Apr 29, 2026
645b046
Refactor: CacheOptions.IsDisabledFor helper; tighten OBO disabled-cac…
Robbie-Microsoft Apr 29, 2026
fa83577
Minor: use CacheOptions.IsDisabledFor in validation; fix test doc; ad…
Robbie-Microsoft Apr 29, 2026
bec7607
Fix assertion message typo: IsInternalCacheDisabled -> MsalError.Inte…
Robbie-Microsoft Apr 29, 2026
246a214
Update src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
Robbie-Microsoft Apr 29, 2026
77f2e1f
Update src/client/Microsoft.Identity.Client/MsalError.cs
Robbie-Microsoft Apr 29, 2026
a7e7ec4
Fix: fully-qualify CacheOptions cref in AuthenticationResultExtension…
Robbie-Microsoft Apr 29, 2026
18a544c
Fix: throw at config time when DisableInternalCacheOptions is set on …
Robbie-Microsoft Apr 30, 2026
98682d1
Merge remote-tracking branch 'origin/main' into rginsburg/mtls_expose…
Robbie-Microsoft Apr 30, 2026
2f11fe9
Fix: update GetRefreshToken() doc to reflect RT may come from request…
Robbie-Microsoft Apr 30, 2026
3d12ae1
Merge main into branch; resolve PublicAPI.Unshipped.txt conflicts
Robbie-Microsoft Apr 30, 2026
7f12f0c
Fix: remove redundant null check in WithCacheOptions; restore PublicA…
Robbie-Microsoft Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 39 additions & 1 deletion src/client/Microsoft.Identity.Client/AppConfig/CacheOptions.cs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,6 +23,26 @@ public static CacheOptions EnableSharedCacheOptions
}
}

/// <summary>
/// Options that disable MSAL's internal in-memory token cache entirely.
/// Use this when your application manages its own token cache lifecycle.
/// <para>When set:</para>
/// <list type="bullet">
/// <item><description>MSAL will not read from or write to the internal cache accessor.</description></item>
/// <item><description>Token cache serialization callbacks (<c>OnBeforeAccess</c>, <c>OnAfterAccess</c>, <c>OnBeforeWrite</c>) will <b>not</b> be invoked.</description></item>
/// <item><description><see cref="IClientApplicationBase.GetAccountsAsync()"/> will always return an empty collection.</description></item>
/// <item><description><see cref="IClientApplicationBase.AcquireTokenSilent(System.Collections.Generic.IEnumerable{string}, IAccount)"/> will throw a <see cref="MsalUiRequiredException"/> with error code <see cref="MsalError.InternalCacheDisabled"/>.</description></item>
/// <item><description><see cref="ILongRunningWebApi.AcquireTokenInLongRunningProcess(System.Collections.Generic.IEnumerable{string}, string)"/> will throw a <see cref="MsalUiRequiredException"/> with error code <see cref="MsalError.InternalCacheDisabled"/>, because the long-running OBO flow requires a cached token to refresh.</description></item>
/// </list>
/// <para>
/// For confidential client flows, retrieve the refresh token using
/// <see cref="Extensibility.AuthenticationResultExtensions.GetRefreshToken(AuthenticationResult)"/>
/// and call <see cref="IByRefreshToken.AcquireTokenByRefreshToken(System.Collections.Generic.IEnumerable{string}, string)"/>
/// to re-acquire a token on subsequent requests, or use another interactive flow.
/// </para>
/// </summary>
Comment thread
Robbie-Microsoft marked this conversation as resolved.
public static CacheOptions DisableInternalCacheOptions => new CacheOptions { IsInternalCacheDisabled = true };
Comment thread
Robbie-Microsoft marked this conversation as resolved.

/// <summary>
/// Constructor for the options with default values.
/// </summary>
Expand Down Expand Up @@ -50,5 +70,23 @@ public CacheOptions(bool useSharedCache)
/// </remarks>
public bool UseSharedCache { get; set; }

/// <summary>
/// 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 <see cref="UseSharedCache"/>.
/// </summary>
/// <remarks>
/// When enabled, <see cref="IClientApplicationBase.AcquireTokenSilent(System.Collections.Generic.IEnumerable{string}, IAccount)"/>
/// and <see cref="ILongRunningWebApi.AcquireTokenInLongRunningProcess(System.Collections.Generic.IEnumerable{string}, string)"/>
/// will throw a <see cref="MsalUiRequiredException"/> with error code <see cref="MsalError.InternalCacheDisabled"/>.
/// </remarks>
public bool IsInternalCacheDisabled { get; set; }
Comment thread
Robbie-Microsoft marked this conversation as resolved.

/// <summary>
/// Returns <c>true</c> if the given <see cref="CacheOptions"/> instance has the internal cache disabled.
/// Safe to call with a <c>null</c> argument (returns <c>false</c>).
/// </summary>
internal static bool IsDisabledFor(CacheOptions options) => options?.IsInternalCacheDisabled == true;

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,12 @@ private AuthenticationResult(
/// </summary>
public ClaimsPrincipal ClaimsPrincipal { get; set; }

/// <summary>
/// The refresh token returned in the authentication response, if any.
/// Access via <see cref="Extensibility.AuthenticationResultExtensions.GetRefreshToken"/>.
/// </summary>
internal string RefreshToken { get; set; }

internal ApiEvent ApiEvent { get; set; }

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -27,6 +27,10 @@ public enum CacheRefreshReason
/// <summary>
/// When the token request goes to the identity provider because refresh_in was used and the existing token needs to be refreshed
/// </summary>
ProactivelyRefreshed = 4
ProactivelyRefreshed = 4,
/// <summary>
/// When the token request goes to the identity provider because the internal token cache has been disabled via <see cref="CacheOptions.DisableInternalCacheOptions"/>.
/// </summary>
CacheDisabled = 5
}
}
82 changes: 71 additions & 11 deletions src/client/Microsoft.Identity.Client/Cache/CacheSessionManager.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -42,34 +41,73 @@ public CacheSessionManager(
#region ICacheSessionManager implementation
public ITokenCacheInternal TokenCacheInternal { get; }

public async Task<MsalAccessTokenCacheItem> FindAccessTokenAsync()
private bool IsInternalCacheDisabled =>
CacheOptions.IsDisabledFor(_requestParams.RequestContext.ServiceBundle.Config.AccessorOptions);

Comment thread
Robbie-Microsoft marked this conversation as resolved.
private bool ShouldSkipInternalCacheRead(string operationName)
{
if (!IsInternalCacheDisabled)
{
return false;
}
_requestParams.RequestContext.Logger.Verbose(
() => $"[Cache Session Manager] Internal cache disabled. Skipping {operationName}.");
return true;
}

Comment thread
Robbie-Microsoft marked this conversation as resolved.
private async Task RefreshCacheForReadOperationsIfEnabledAsync(string operationName)
{
if (ShouldSkipInternalCacheRead(operationName))
{
return;
}
await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
}

public async Task<MsalAccessTokenCacheItem> FindAccessTokenAsync()
{
await RefreshCacheForReadOperationsIfEnabledAsync("access token lookup").ConfigureAwait(false);
if (IsInternalCacheDisabled)
{
return null;
}
Comment thread
Robbie-Microsoft marked this conversation as resolved.
return await TokenCacheInternal.FindAccessTokenAsync(_requestParams).ConfigureAwait(false);
}
Comment thread
Robbie-Microsoft marked this conversation as resolved.

public async Task<Tuple<MsalAccessTokenCacheItem, MsalIdTokenCacheItem, Account>> 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<Account> 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<MsalIdTokenCacheItem> 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<MsalRefreshTokenCacheItem> FindFamilyRefreshTokenAsync(string familyId)
{
await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
await RefreshCacheForReadOperationsIfEnabledAsync("family refresh token lookup").ConfigureAwait(false);
if (IsInternalCacheDisabled)
{
return null;
}

if (string.IsNullOrEmpty(familyId))
{
Expand All @@ -81,19 +119,31 @@ public async Task<MsalRefreshTokenCacheItem> FindFamilyRefreshTokenAsync(string

public async Task<MsalRefreshTokenCacheItem> 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<bool?> 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<IEnumerable<IAccount>> GetAccountsAsync()
{
await RefreshCacheForReadOperationsAsync().ConfigureAwait(false);
await RefreshCacheForReadOperationsIfEnabledAsync("accounts lookup").ConfigureAwait(false);
Comment thread
Robbie-Microsoft marked this conversation as resolved.
if (IsInternalCacheDisabled)
{
return System.Array.Empty<IAccount>();
}
return await TokenCacheInternal.GetAccountsAsync(_requestParams).ConfigureAwait(false);
}

Expand Down Expand Up @@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace Microsoft.Identity.Client.Extensibility
{
/// <summary>
/// Extension methods for <see cref="AuthenticationResult"/>.
/// </summary>
public static class AuthenticationResultExtensions
{
/// <summary>
/// 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 <see cref="Microsoft.Identity.Client.CacheOptions.DisableInternalCacheOptions"/>.
/// </summary>
/// <param name="result">The authentication result.</param>
/// <returns>
/// 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; <c>null</c> 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 <c>null</c>.
/// </returns>
/// <remarks>
/// Refresh tokens are long-lived credentials. Store them securely and never expose them to end users or untrusted code.
/// </remarks>
public static string GetRefreshToken(this AuthenticationResult result)
Comment thread
Robbie-Microsoft marked this conversation as resolved.
{
return result?.RefreshToken;
}
Comment thread
Robbie-Microsoft marked this conversation as resolved.
Comment thread
Robbie-Microsoft marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -51,6 +51,12 @@ protected override async Task<AuthenticationResult> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -50,6 +50,11 @@ protected override async Task<AuthenticationResult> 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)
{
Expand All @@ -58,6 +63,22 @@ protected override async Task<AuthenticationResult> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ internal abstract class RequestBase
internal ICacheSessionManager CacheManager => AuthenticationRequestParameters.CacheSessionManager;
internal IServiceBundle ServiceBundle { get; }

/// <summary>
/// Returns <c>true</c> if the internal token cache is disabled via <c>CacheOptions.DisableInternalCacheOptions</c>.
/// </summary>
protected bool IsInternalCacheDisabled =>
CacheOptions.IsDisabledFor(ServiceBundle.Config.AccessorOptions);

protected RequestBase(
IServiceBundle serviceBundle,
AuthenticationRequestParameters authenticationRequestParameters,
Expand Down Expand Up @@ -349,7 +355,7 @@ protected async Task<AuthenticationResult> CacheTokenResponseAndCreateAuthentica
#if !MOBILE
atItem?.AddAdditionalCacheParameters(clientInfoFromServer?.AdditionalResponseParameters);
#endif
return await AuthenticationResult.CreateAsync(
var authResult = await AuthenticationResult.CreateAsync(
atItem,
idtItem,
AuthenticationRequestParameters.AuthenticationScheme,
Expand All @@ -360,6 +366,11 @@ protected async Task<AuthenticationResult> CacheTokenResponseAndCreateAuthentica
msalTokenResponse.SpaAuthCode,
msalTokenResponse.CreateExtensionDataStringMap(),
cancellationToken).ConfigureAwait(false);

authResult.RefreshToken = AuthenticationRequestParameters.AppConfig.IsConfidentialClient
? msalTokenResponse.RefreshToken
: null;
Comment thread
Robbie-Microsoft marked this conversation as resolved.
return authResult;
}

protected virtual void ValidateAccountIdentifiers(ClientInfo fromServer)
Expand Down
Loading
Loading