Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ namespace Microsoft.Identity.Client
public sealed class AcquireTokenForClientParameterBuilder :
AbstractConfidentialClientAcquireTokenParameterBuilder<AcquireTokenForClientParameterBuilder>
{
private AcquireTokenForClientParameters Parameters { get; } = new AcquireTokenForClientParameters();
internal AcquireTokenForClientParameters Parameters { get; } = new AcquireTokenForClientParameters();

/// <inheritdoc/>
internal AcquireTokenForClientParameterBuilder(IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor)
Expand Down Expand Up @@ -183,6 +183,14 @@ protected override void Validate()

base.Validate();

// Force refresh + AccessTokenHashToRefresh APIs cannot be used together
if (Parameters.ForceRefresh && !string.IsNullOrEmpty(Parameters.AccessTokenHashToRefresh))
{
throw new MsalClientException(
MsalError.ForceRefreshNotCompatibleWithTokenHash,
MsalErrorMessage.ForceRefreshAndTokenHasNotCompatible);
}

if (Parameters.SendX5C == null)
{
Parameters.SendX5C = this.ServiceBundle.Config.SendX5C;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@ namespace Microsoft.Identity.Client.ApiConfig.Parameters
{
internal class AcquireTokenForClientParameters : AbstractAcquireTokenConfidentialClientParameters, IAcquireTokenParameters
{
public bool ForceRefresh { get; set; }

/// <summary>
/// The SHA-256 hash of the access token that should be refreshed.
/// If set, token refresh will occur only if a matching token is found in cache.
/// </summary>
public bool ForceRefresh { get; set; }
public string AccessTokenHashToRefresh { get; set; }

/// <inheritdoc/>
public void LogParameters(ILoggerAdapter logger)
Expand All @@ -22,6 +26,7 @@ public void LogParameters(ILoggerAdapter logger)
builder.AppendLine("=== AcquireTokenForClientParameters ===");
builder.AppendLine("SendX5C: " + SendX5C);
builder.AppendLine("ForceRefresh: " + ForceRefresh);
builder.AppendLine($"AccessTokenHashToRefresh: {!string.IsNullOrEmpty(AccessTokenHashToRefresh)}");
logger.Info(builder.ToString());
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.ApiConfig.Parameters;

namespace Microsoft.Identity.Client.RP
{
/// <summary>
/// Resource Provider extensibility methods for AcquireTokenForClientParameterBuilder
/// </summary>
public static class AcquireTokenForClientParameterBuilderForResourceProviders
{
/// <summary>
/// Configures the SDK to not retrieve a token from the cache if it matches the SHA256 hash
/// of the token configured. Similar to WithForceRefresh(bool) API, but instead of bypassing
/// the cache for all tokens, the cache bypass only occurs for 1 token
/// </summary>
/// <param name="builder">The existing AcquireTokenForClientParameterBuilder instance.</param>
/// <param name="hash">
/// A Base64-encoded SHA-256 hash of the token (UTF-8). For example:
/// <c>Convert.ToBase64String(SHA256(Encoding.UTF8.GetBytes(accessToken)))</c>.
/// </param>
/// <returns>The builder to chain the .With methods.</returns>
public static AcquireTokenForClientParameterBuilder WithAccessTokenSha256ToRefresh(
this AcquireTokenForClientParameterBuilder builder,
string hash)
{
if (!string.IsNullOrWhiteSpace(hash))
{
builder.Parameters.AccessTokenHashToRefresh = hash;
}

return builder;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Identity.Client.ApiConfig.Parameters;
Expand All @@ -10,6 +14,7 @@
using Microsoft.Identity.Client.Extensibility;
using Microsoft.Identity.Client.Instance;
using Microsoft.Identity.Client.OAuth2;
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
using Microsoft.Identity.Client.Utils;

namespace Microsoft.Identity.Client.Internal.Requests
Expand All @@ -18,6 +23,7 @@ internal class ClientCredentialRequest : RequestBase
{
private readonly AcquireTokenForClientParameters _clientParameters;
private static readonly SemaphoreSlim s_semaphoreSlim = new SemaphoreSlim(1, 1);
private readonly ICryptographyManager _cryptoManager;

public ClientCredentialRequest(
IServiceBundle serviceBundle,
Expand All @@ -26,6 +32,7 @@ public ClientCredentialRequest(
: base(serviceBundle, authenticationRequestParameters, clientParameters)
{
_clientParameters = clientParameters;
_cryptoManager = serviceBundle.PlatformProxy.CryptographyManager;
}

protected override async Task<AuthenticationResult> ExecuteAsync(CancellationToken cancellationToken)
Expand All @@ -44,14 +51,22 @@ protected override async Task<AuthenticationResult> ExecuteAsync(CancellationTok
{
logger.Error(MsalErrorMessage.ClientCredentialWrongAuthority);
}

AuthenticationResult authResult;

// Skip checking cache when force refresh or claims are specified
if (_clientParameters.ForceRefresh || !string.IsNullOrEmpty(AuthenticationRequestParameters.Claims))
// Skip cache if either:
// 1) ForceRefresh is set, or
// 2) Claims are specified and there is no AccessTokenHashToRefresh.
// This ensures that when both claims and AccessTokenHashToRefresh are set,
// we do NOT skip the cache, allowing MSAL to attempt retrieving a matching
// cached token by the provided hash before requesting a new token.
bool skipCache = _clientParameters.ForceRefresh ||
(!string.IsNullOrEmpty(AuthenticationRequestParameters.Claims) &&
string.IsNullOrEmpty(_clientParameters.AccessTokenHashToRefresh));

if (skipCache)
{
AuthenticationRequestParameters.RequestContext.ApiEvent.CacheInfo = CacheRefreshReason.ForceRefreshOrClaims;
logger.Info("[ClientCredentialRequest] Skipped looking for a cached access token because ForceRefresh or Claims were set.");
logger.Info("[ClientCredentialRequest] Skipped looking for a cached access token because either of ForceRefresh, Claims or AccessTokenHashToRefresh were set.");
authResult = await GetAccessTokenAsync(cancellationToken, logger).ConfigureAwait(false);
return authResult;
}
Expand Down Expand Up @@ -187,20 +202,78 @@ private async Task<AuthenticationResult> SendTokenRequestToAppTokenProviderAsync
return authResult;
}

/// <summary>
/// Checks if the token should be used from the cache and returns the cached access token if applicable.
/// </summary>
/// <returns></returns>
private async Task<MsalAccessTokenCacheItem> GetCachedAccessTokenAsync()
{
MsalAccessTokenCacheItem cachedAccessTokenItem = await CacheManager.FindAccessTokenAsync().ConfigureAwait(false);
// Fetch the cache item (could be null if none found).
MsalAccessTokenCacheItem cacheItem =
await CacheManager.FindAccessTokenAsync().ConfigureAwait(false);

if (cachedAccessTokenItem != null && !_clientParameters.ForceRefresh)
// If the item fails any checks (null, or hash mismatch),
if (!ShouldUseCachedToken(cacheItem))
{
AuthenticationRequestParameters.RequestContext.ApiEvent.IsAccessTokenCacheHit = true;
Metrics.IncrementTotalAccessTokensFromCache();
return cachedAccessTokenItem;
return null;
}

return null;
// Otherwise, record a successful cache hit and return the token.
MarkAccessTokenAsCacheHit();
return cacheItem;
}

/// <summary>
/// Checks if the token should be used from the cache.
/// </summary>
/// <param name="cacheItem"></param>
/// <returns></returns>
private bool ShouldUseCachedToken(MsalAccessTokenCacheItem cacheItem)
{
// 1) No cached item
if (cacheItem == null)
{
return false;
}

// 2) If the token’s hash matches AccessTokenHashToRefresh, ignore it
if (!string.IsNullOrEmpty(_clientParameters.AccessTokenHashToRefresh) &&
IsMatchingTokenHash(cacheItem.Secret, _clientParameters.AccessTokenHashToRefresh))
{
AuthenticationRequestParameters.RequestContext.Logger.Info(
"[ClientCredentialRequest] A cached token was found and its hash matches AccessTokenHashToRefresh, so it is ignored.");
return false;
}

return true;
}

/// <summary>
/// Checks if the token hash matches the hash provided in AccessTokenHashToRefresh.
/// </summary>
/// <param name="tokenSecret"></param>
/// <param name="accessTokenHashToRefresh"></param>
/// <returns></returns>
private bool IsMatchingTokenHash(string tokenSecret, string accessTokenHashToRefresh)
{
string cachedTokenHash = _cryptoManager.CreateSha256Hash(tokenSecret);
return string.Equals(cachedTokenHash, accessTokenHashToRefresh, StringComparison.Ordinal);
}

/// <summary>
/// Marks the request as a cache hit and increments the cache hit count.
/// </summary>
private void MarkAccessTokenAsCacheHit()
{
AuthenticationRequestParameters.RequestContext.ApiEvent.IsAccessTokenCacheHit = true;
Metrics.IncrementTotalAccessTokensFromCache();
}

/// <summary>
/// returns the cached access token item
/// </summary>
/// <param name="cachedAccessTokenItem"></param>
/// <returns></returns>
private AuthenticationResult CreateAuthenticationResultFromCache(MsalAccessTokenCacheItem cachedAccessTokenItem)
{
AuthenticationResult authResult = new AuthenticationResult(
Expand All @@ -216,6 +289,11 @@ private AuthenticationResult CreateAuthenticationResultFromCache(MsalAccessToken
return authResult;
}

/// <summary>
/// Gets overriden scopes for client credentials flow
/// </summary>
/// <param name="inputScopes"></param>
/// <returns></returns>
protected override SortedSet<string> GetOverriddenScopes(ISet<string> inputScopes)
{
// Client credentials should not add the reserved scopes
Expand All @@ -224,6 +302,10 @@ protected override SortedSet<string> GetOverriddenScopes(ISet<string> inputScope
return new SortedSet<string>(inputScopes);
}

/// <summary>
/// Gets the body parameters for the client credentials flow
/// </summary>
/// <returns></returns>
private Dictionary<string, string> GetBodyParameters()
{
var dict = new Dictionary<string, string>
Expand All @@ -235,6 +317,11 @@ private Dictionary<string, string> GetBodyParameters()
return dict;
}

/// <summary>
/// Gets the CCS header for the client credentials flow
/// </summary>
/// <param name="additionalBodyParameters"></param>
/// <returns></returns>
protected override KeyValuePair<string, string>? GetCcsHeader(IDictionary<string, string> additionalBodyParameters)
{
return null;
Expand Down
11 changes: 11 additions & 0 deletions src/client/Microsoft.Identity.Client/MsalError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1177,5 +1177,16 @@ public static class MsalError
/// <para>Mitigation:</para> Ensure that the AzureRegion configuration is set when using mTLS PoP as it requires a regional endpoint.
/// </summary>
public const string RegionRequiredForMtlsPop = "region_required_for_mtls_pop";

/// <summary>
/// <para>What happened?</para> The operation attempted to force a token refresh while also using a token hash.
/// These two options are incompatible because forcing a refresh bypasses token caching,
/// which conflicts with token hash validation.
/// <para>Mitigation:</para>
/// - Ensure that `force_refresh` is not set to `true` when using a token hash.
/// - Review the request parameters to ensure they are not conflicting.
/// - If token hashing is required, allow the cached token to be used instead of forcing a refresh.
/// </summary>
public const string ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible";
}
}
1 change: 1 addition & 0 deletions src/client/Microsoft.Identity.Client/MsalErrorMessage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,5 +437,6 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName)
public const string MtlsInvalidAuthorityTypeMessage = "mTLS PoP is only supported for AAD authority type. See https://aka.ms/msal-net-pop for details.";
public const string MtlsNonTenantedAuthorityNotAllowedMessage = "mTLS authentication requires a tenanted authority. Using 'common', 'organizations', or similar non-tenanted authorities is not allowed. Please provide an authority with a specific tenant ID (e.g., 'https://login.microsoftonline.com/{tenantId}'). See https://aka.ms/msal-net-pop for details.";
public const string RegionRequiredForMtlsPopMessage = "Regional auto-detect failed. mTLS Proof-of-Possession requires a region to be specified, as there is no global endpoint for mTLS. See https://aka.ms/msal-net-pop for details.";
public const string ForceRefreshAndTokenHasNotCompatible = "Cannot specify ForceRefresh and AccessTokenSha256ToRefresh in the same request.";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,7 @@ public async Task ManagedIdentityInvalidRefreshOnThrowsAsync()
}

[TestMethod]
public async Task ManagedIdentityIsProactivelyRefreshedAsync()
public async Task ManagedIdentityIsProActivelyRefreshedAsync()
{
using (new EnvVariableContext())
using (var httpManager = new MockHttpManager(isManagedIdentity: true))
Expand Down
Loading
Loading