Skip to content

Commit e090343

Browse files
gladjohnGladwinJohnsonbgavrilMSneha-bhargava
authored
Enhancing Token Refresh Control in MSAL: Introducing .WithAccessTokenSha256ToRefresh() in AcquireTokenForClient Flows (#5179)
* new-feature * Update src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs Co-authored-by: Bogdan Gavril <bogavril@microsoft.com> * pr comments * pr comments * pr comments * remove not used test * pr comments * Update src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs Co-authored-by: Neha Bhargava <61847233+neha-bhargava@users.noreply.github.com> * remove WithExperimentalFeatures from tests as it is not needed * Update src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs Co-authored-by: Neha Bhargava <61847233+neha-bhargava@users.noreply.github.com> --------- Co-authored-by: Gladwin Johnson <gljohns@microsoft.com> Co-authored-by: Bogdan Gavril <bogavril@microsoft.com> Co-authored-by: Neha Bhargava <61847233+neha-bhargava@users.noreply.github.com>
1 parent cd5172d commit e090343

File tree

14 files changed

+387
-13
lines changed

14 files changed

+387
-13
lines changed

src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ namespace Microsoft.Identity.Client
2828
public sealed class AcquireTokenForClientParameterBuilder :
2929
AbstractConfidentialClientAcquireTokenParameterBuilder<AcquireTokenForClientParameterBuilder>
3030
{
31-
private AcquireTokenForClientParameters Parameters { get; } = new AcquireTokenForClientParameters();
31+
internal AcquireTokenForClientParameters Parameters { get; } = new AcquireTokenForClientParameters();
3232

3333
/// <inheritdoc/>
3434
internal AcquireTokenForClientParameterBuilder(IConfidentialClientApplicationExecutor confidentialClientApplicationExecutor)
@@ -183,6 +183,14 @@ protected override void Validate()
183183

184184
base.Validate();
185185

186+
// Force refresh + AccessTokenHashToRefresh APIs cannot be used together
187+
if (Parameters.ForceRefresh && !string.IsNullOrEmpty(Parameters.AccessTokenHashToRefresh))
188+
{
189+
throw new MsalClientException(
190+
MsalError.ForceRefreshNotCompatibleWithTokenHash,
191+
MsalErrorMessage.ForceRefreshAndTokenHasNotCompatible);
192+
}
193+
186194
if (Parameters.SendX5C == null)
187195
{
188196
Parameters.SendX5C = this.ServiceBundle.Config.SendX5C;

src/client/Microsoft.Identity.Client/ApiConfig/Parameters/AcquireTokenForClientParameters.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ namespace Microsoft.Identity.Client.ApiConfig.Parameters
99
{
1010
internal class AcquireTokenForClientParameters : AbstractAcquireTokenConfidentialClientParameters, IAcquireTokenParameters
1111
{
12+
public bool ForceRefresh { get; set; }
13+
1214
/// <summary>
15+
/// The SHA-256 hash of the access token that should be refreshed.
16+
/// If set, token refresh will occur only if a matching token is found in cache.
1317
/// </summary>
14-
public bool ForceRefresh { get; set; }
18+
public string AccessTokenHashToRefresh { get; set; }
1519

1620
/// <inheritdoc/>
1721
public void LogParameters(ILoggerAdapter logger)
@@ -22,6 +26,7 @@ public void LogParameters(ILoggerAdapter logger)
2226
builder.AppendLine("=== AcquireTokenForClientParameters ===");
2327
builder.AppendLine("SendX5C: " + SendX5C);
2428
builder.AppendLine("ForceRefresh: " + ForceRefresh);
29+
builder.AppendLine($"AccessTokenHashToRefresh: {!string.IsNullOrEmpty(AccessTokenHashToRefresh)}");
2530
logger.Info(builder.ToString());
2631
}
2732
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using Microsoft.Identity.Client;
6+
using Microsoft.Identity.Client.ApiConfig.Parameters;
7+
8+
namespace Microsoft.Identity.Client.RP
9+
{
10+
/// <summary>
11+
/// Resource Provider extensibility methods for AcquireTokenForClientParameterBuilder
12+
/// </summary>
13+
public static class AcquireTokenForClientParameterBuilderForResourceProviders
14+
{
15+
/// <summary>
16+
/// Configures the SDK to not retrieve a token from the cache if it matches the SHA256 hash
17+
/// of the token configured. Similar to WithForceRefresh(bool) API, but instead of bypassing
18+
/// the cache for all tokens, the cache bypass only occurs for 1 token
19+
/// </summary>
20+
/// <param name="builder">The existing AcquireTokenForClientParameterBuilder instance.</param>
21+
/// <param name="hash">
22+
/// A Base64-encoded SHA-256 hash of the token (UTF-8). For example:
23+
/// <c>Convert.ToBase64String(SHA256(Encoding.UTF8.GetBytes(accessToken)))</c>.
24+
/// </param>
25+
/// <returns>The builder to chain the .With methods.</returns>
26+
public static AcquireTokenForClientParameterBuilder WithAccessTokenSha256ToRefresh(
27+
this AcquireTokenForClientParameterBuilder builder,
28+
string hash)
29+
{
30+
if (!string.IsNullOrWhiteSpace(hash))
31+
{
32+
builder.Parameters.AccessTokenHashToRefresh = hash;
33+
}
34+
35+
return builder;
36+
}
37+
}
38+
}

src/client/Microsoft.Identity.Client/Internal/Requests/ClientCredentialRequest.cs

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
using System;
45
using System.Collections.Generic;
6+
using System.Security.Cryptography;
7+
using System.Text;
8+
using System.Text.RegularExpressions;
59
using System.Threading;
610
using System.Threading.Tasks;
711
using Microsoft.Identity.Client.ApiConfig.Parameters;
@@ -10,6 +14,7 @@
1014
using Microsoft.Identity.Client.Extensibility;
1115
using Microsoft.Identity.Client.Instance;
1216
using Microsoft.Identity.Client.OAuth2;
17+
using Microsoft.Identity.Client.PlatformsCommon.Interfaces;
1318
using Microsoft.Identity.Client.Utils;
1419

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

2228
public ClientCredentialRequest(
2329
IServiceBundle serviceBundle,
@@ -26,6 +32,7 @@ public ClientCredentialRequest(
2632
: base(serviceBundle, authenticationRequestParameters, clientParameters)
2733
{
2834
_clientParameters = clientParameters;
35+
_cryptoManager = serviceBundle.PlatformProxy.CryptographyManager;
2936
}
3037

3138
protected override async Task<AuthenticationResult> ExecuteAsync(CancellationToken cancellationToken)
@@ -44,14 +51,22 @@ protected override async Task<AuthenticationResult> ExecuteAsync(CancellationTok
4451
{
4552
logger.Error(MsalErrorMessage.ClientCredentialWrongAuthority);
4653
}
47-
4854
AuthenticationResult authResult;
4955

50-
// Skip checking cache when force refresh or claims are specified
51-
if (_clientParameters.ForceRefresh || !string.IsNullOrEmpty(AuthenticationRequestParameters.Claims))
56+
// Skip cache if either:
57+
// 1) ForceRefresh is set, or
58+
// 2) Claims are specified and there is no AccessTokenHashToRefresh.
59+
// This ensures that when both claims and AccessTokenHashToRefresh are set,
60+
// we do NOT skip the cache, allowing MSAL to attempt retrieving a matching
61+
// cached token by the provided hash before requesting a new token.
62+
bool skipCache = _clientParameters.ForceRefresh ||
63+
(!string.IsNullOrEmpty(AuthenticationRequestParameters.Claims) &&
64+
string.IsNullOrEmpty(_clientParameters.AccessTokenHashToRefresh));
65+
66+
if (skipCache)
5267
{
5368
AuthenticationRequestParameters.RequestContext.ApiEvent.CacheInfo = CacheRefreshReason.ForceRefreshOrClaims;
54-
logger.Info("[ClientCredentialRequest] Skipped looking for a cached access token because ForceRefresh or Claims were set.");
69+
logger.Info("[ClientCredentialRequest] Skipped looking for a cached access token because either of ForceRefresh, Claims or AccessTokenHashToRefresh were set.");
5570
authResult = await GetAccessTokenAsync(cancellationToken, logger).ConfigureAwait(false);
5671
return authResult;
5772
}
@@ -187,20 +202,78 @@ private async Task<AuthenticationResult> SendTokenRequestToAppTokenProviderAsync
187202
return authResult;
188203
}
189204

205+
/// <summary>
206+
/// Checks if the token should be used from the cache and returns the cached access token if applicable.
207+
/// </summary>
208+
/// <returns></returns>
190209
private async Task<MsalAccessTokenCacheItem> GetCachedAccessTokenAsync()
191210
{
192-
MsalAccessTokenCacheItem cachedAccessTokenItem = await CacheManager.FindAccessTokenAsync().ConfigureAwait(false);
211+
// Fetch the cache item (could be null if none found).
212+
MsalAccessTokenCacheItem cacheItem =
213+
await CacheManager.FindAccessTokenAsync().ConfigureAwait(false);
193214

194-
if (cachedAccessTokenItem != null && !_clientParameters.ForceRefresh)
215+
// If the item fails any checks (null, or hash mismatch),
216+
if (!ShouldUseCachedToken(cacheItem))
195217
{
196-
AuthenticationRequestParameters.RequestContext.ApiEvent.IsAccessTokenCacheHit = true;
197-
Metrics.IncrementTotalAccessTokensFromCache();
198-
return cachedAccessTokenItem;
218+
return null;
199219
}
200220

201-
return null;
221+
// Otherwise, record a successful cache hit and return the token.
222+
MarkAccessTokenAsCacheHit();
223+
return cacheItem;
224+
}
225+
226+
/// <summary>
227+
/// Checks if the token should be used from the cache.
228+
/// </summary>
229+
/// <param name="cacheItem"></param>
230+
/// <returns></returns>
231+
private bool ShouldUseCachedToken(MsalAccessTokenCacheItem cacheItem)
232+
{
233+
// 1) No cached item
234+
if (cacheItem == null)
235+
{
236+
return false;
237+
}
238+
239+
// 2) If the token’s hash matches AccessTokenHashToRefresh, ignore it
240+
if (!string.IsNullOrEmpty(_clientParameters.AccessTokenHashToRefresh) &&
241+
IsMatchingTokenHash(cacheItem.Secret, _clientParameters.AccessTokenHashToRefresh))
242+
{
243+
AuthenticationRequestParameters.RequestContext.Logger.Info(
244+
"[ClientCredentialRequest] A cached token was found and its hash matches AccessTokenHashToRefresh, so it is ignored.");
245+
return false;
246+
}
247+
248+
return true;
249+
}
250+
251+
/// <summary>
252+
/// Checks if the token hash matches the hash provided in AccessTokenHashToRefresh.
253+
/// </summary>
254+
/// <param name="tokenSecret"></param>
255+
/// <param name="accessTokenHashToRefresh"></param>
256+
/// <returns></returns>
257+
private bool IsMatchingTokenHash(string tokenSecret, string accessTokenHashToRefresh)
258+
{
259+
string cachedTokenHash = _cryptoManager.CreateSha256Hash(tokenSecret);
260+
return string.Equals(cachedTokenHash, accessTokenHashToRefresh, StringComparison.Ordinal);
261+
}
262+
263+
/// <summary>
264+
/// Marks the request as a cache hit and increments the cache hit count.
265+
/// </summary>
266+
private void MarkAccessTokenAsCacheHit()
267+
{
268+
AuthenticationRequestParameters.RequestContext.ApiEvent.IsAccessTokenCacheHit = true;
269+
Metrics.IncrementTotalAccessTokensFromCache();
202270
}
203271

272+
/// <summary>
273+
/// returns the cached access token item
274+
/// </summary>
275+
/// <param name="cachedAccessTokenItem"></param>
276+
/// <returns></returns>
204277
private AuthenticationResult CreateAuthenticationResultFromCache(MsalAccessTokenCacheItem cachedAccessTokenItem)
205278
{
206279
AuthenticationResult authResult = new AuthenticationResult(
@@ -216,6 +289,11 @@ private AuthenticationResult CreateAuthenticationResultFromCache(MsalAccessToken
216289
return authResult;
217290
}
218291

292+
/// <summary>
293+
/// Gets overriden scopes for client credentials flow
294+
/// </summary>
295+
/// <param name="inputScopes"></param>
296+
/// <returns></returns>
219297
protected override SortedSet<string> GetOverriddenScopes(ISet<string> inputScopes)
220298
{
221299
// Client credentials should not add the reserved scopes
@@ -224,6 +302,10 @@ protected override SortedSet<string> GetOverriddenScopes(ISet<string> inputScope
224302
return new SortedSet<string>(inputScopes);
225303
}
226304

305+
/// <summary>
306+
/// Gets the body parameters for the client credentials flow
307+
/// </summary>
308+
/// <returns></returns>
227309
private Dictionary<string, string> GetBodyParameters()
228310
{
229311
var dict = new Dictionary<string, string>
@@ -235,6 +317,11 @@ private Dictionary<string, string> GetBodyParameters()
235317
return dict;
236318
}
237319

320+
/// <summary>
321+
/// Gets the CCS header for the client credentials flow
322+
/// </summary>
323+
/// <param name="additionalBodyParameters"></param>
324+
/// <returns></returns>
238325
protected override KeyValuePair<string, string>? GetCcsHeader(IDictionary<string, string> additionalBodyParameters)
239326
{
240327
return null;

src/client/Microsoft.Identity.Client/MsalError.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,5 +1177,16 @@ public static class MsalError
11771177
/// <para>Mitigation:</para> Ensure that the AzureRegion configuration is set when using mTLS PoP as it requires a regional endpoint.
11781178
/// </summary>
11791179
public const string RegionRequiredForMtlsPop = "region_required_for_mtls_pop";
1180+
1181+
/// <summary>
1182+
/// <para>What happened?</para> The operation attempted to force a token refresh while also using a token hash.
1183+
/// These two options are incompatible because forcing a refresh bypasses token caching,
1184+
/// which conflicts with token hash validation.
1185+
/// <para>Mitigation:</para>
1186+
/// - Ensure that `force_refresh` is not set to `true` when using a token hash.
1187+
/// - Review the request parameters to ensure they are not conflicting.
1188+
/// - If token hashing is required, allow the cached token to be used instead of forcing a refresh.
1189+
/// </summary>
1190+
public const string ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible";
11801191
}
11811192
}

src/client/Microsoft.Identity.Client/MsalErrorMessage.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,5 +437,6 @@ public static string InvalidTokenProviderResponseValue(string invalidValueName)
437437
public const string MtlsInvalidAuthorityTypeMessage = "mTLS PoP is only supported for AAD authority type. See https://aka.ms/msal-net-pop for details.";
438438
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.";
439439
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.";
440+
public const string ForceRefreshAndTokenHasNotCompatible = "Cannot specify ForceRefresh and AccessTokenSha256ToRefresh in the same request.";
440441
}
441442
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
2+
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
3+
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
2+
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
3+
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
2+
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
3+
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
2+
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
3+
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
2+
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
3+
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
const Microsoft.Identity.Client.MsalError.ForceRefreshNotCompatibleWithTokenHash = "force_refresh_and_token_hash_not_compatible" -> string
2+
Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders
3+
static Microsoft.Identity.Client.RP.AcquireTokenForClientParameterBuilderForResourceProviders.WithAccessTokenSha256ToRefresh(this Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder builder, string hash) -> Microsoft.Identity.Client.AcquireTokenForClientParameterBuilder

tests/Microsoft.Identity.Test.Unit/ManagedIdentityTests/ManagedIdentityTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,7 @@ public async Task ManagedIdentityInvalidRefreshOnThrowsAsync()
895895
}
896896

897897
[TestMethod]
898-
public async Task ManagedIdentityIsProactivelyRefreshedAsync()
898+
public async Task ManagedIdentityIsProActivelyRefreshedAsync()
899899
{
900900
using (new EnvVariableContext())
901901
using (var httpManager = new MockHttpManager(isManagedIdentity: true))

0 commit comments

Comments
 (0)