Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -28,7 +28,8 @@ internal MsalAccessTokenCacheItem(
string tenantId,
string homeAccountId,
string keyId = null,
string oboCacheKey = null)
string oboCacheKey = null,
string oboAssertionHash = null)
: this(
scopes: response.Scope, // token providers send pre-sorted (alphabetically) scopes
cachedAt: DateTimeOffset.UtcNow,
Expand All @@ -45,11 +46,12 @@ internal MsalAccessTokenCacheItem(
RawClientInfo = response.ClientInfo;
HomeAccountId = homeAccountId;
OboCacheKey = oboCacheKey;
OboAssertionHash = oboAssertionHash;

InitCacheKey();
}

internal /* for test */ MsalAccessTokenCacheItem(
internal MsalAccessTokenCacheItem(
string preferredCacheEnv,
string clientId,
string scopes,
Expand All @@ -63,7 +65,8 @@ internal MsalAccessTokenCacheItem(
string keyId = null,
DateTimeOffset? refreshOn = null,
string tokenType = StorageJsonValues.TokenTypeBearer,
string oboCacheKey = null)
string oboCacheKey = null,
string oboAssertionHash = null)
: this(scopes, cachedAt, expiresOn, extendedExpiresOn, refreshOn, tenantId, keyId, tokenType)
{
Environment = preferredCacheEnv;
Expand All @@ -72,6 +75,7 @@ internal MsalAccessTokenCacheItem(
RawClientInfo = rawClientInfo;
HomeAccountId = homeAccountId;
OboCacheKey = oboCacheKey;
OboAssertionHash = oboAssertionHash;

InitCacheKey();
}
Expand Down Expand Up @@ -121,7 +125,8 @@ internal MsalAccessTokenCacheItem WithExpiresOn(DateTimeOffset expiresOn)
KeyId,
RefreshOn,
TokenType,
OboCacheKey);
OboCacheKey,
OboAssertionHash);

return newAtItem;
}
Expand Down Expand Up @@ -186,10 +191,16 @@ internal string TenantId

/// <summary>
/// Used to find the token in the cache.
/// Can be a token assertion hash (normal OBO flow) or a user provided key (long-running OBO flow).
/// Can be a token assertion hash (normal OBO flow) or a user-provided key (long-running OBO flow).
/// </summary>
internal string OboCacheKey { get; set; }

/// <summary>
/// Only used in InitiateLongRunningProcessInWebApi to compare request and cached assertions.
/// Always set to the assertion hash.
/// </summary>
internal string OboAssertionHash { get; set; }

/// <summary>
/// Used when the token is bound to a public / private key pair which is identified by a key id (kid).
/// Currently used by PoP tokens
Expand Down Expand Up @@ -242,7 +253,8 @@ internal static MsalAccessTokenCacheItem FromJObject(JObject j)
extendedExpiresOnUnixTimestamp = ext_expires_on;
}
string tenantId = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.Realm);
string oboCacheKey = JsonHelper.ExtractExistingOrDefault<string>(j, StorageJsonKeys.UserAssertionHash);
string oboCacheKey = JsonHelper.ExtractExistingOrDefault<string>(j, StorageJsonKeys.UserAssertionHashCacheKey);
string oboAssertionHash = JsonHelper.ExtractExistingOrDefault<string>(j, StorageJsonKeys.OboAssertionHash);
string keyId = JsonHelper.ExtractExistingOrDefault<string>(j, StorageJsonKeys.KeyId);
string tokenType = JsonHelper.ExtractExistingOrDefault<string>(j, StorageJsonKeys.TokenType) ?? StorageJsonValues.TokenTypeBearer;
string scopes = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.Target);
Expand All @@ -258,6 +270,7 @@ internal static MsalAccessTokenCacheItem FromJObject(JObject j)
tokenType: tokenType);

item.OboCacheKey = oboCacheKey;
item.OboAssertionHash = oboAssertionHash;
item.PopulateFieldsFromJObject(j);

item.InitCacheKey();
Expand All @@ -272,7 +285,8 @@ internal override JObject ToJObject()
var extExpiresUnixTimestamp = DateTimeHelpers.DateTimeToUnixTimestamp(ExtendedExpiresOn);
SetItemIfValueNotNull(json, StorageJsonKeys.Realm, TenantId);
SetItemIfValueNotNull(json, StorageJsonKeys.Target, ScopeString);
SetItemIfValueNotNull(json, StorageJsonKeys.UserAssertionHash, OboCacheKey);
SetItemIfValueNotNull(json, StorageJsonKeys.UserAssertionHashCacheKey, OboCacheKey);
SetItemIfValueNotNull(json, StorageJsonKeys.OboAssertionHash, OboAssertionHash);
SetItemIfValueNotNull(json, StorageJsonKeys.CachedAt, DateTimeHelpers.DateTimeToUnixTimestamp(CachedAt));
SetItemIfValueNotNull(json, StorageJsonKeys.ExpiresOn, DateTimeHelpers.DateTimeToUnixTimestamp(ExpiresOn));
SetItemIfValueNotNull(json, StorageJsonKeys.ExtendedExpiresOn, extExpiresUnixTimestamp);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,17 @@ public string GetiOSService()
public string FamilyId { get; set; }

/// <summary>
/// Used to find the token in the cache.
/// Can be a token assertion hash (normal OBO flow) or a user provided key (long-running OBO flow).
/// Used to find the token in the cache.
/// Can be a token assertion hash (normal OBO flow) or a user-provided key (long-running OBO flow).
/// </summary>
internal string OboCacheKey { get; set; }

/// <summary>
/// Only used in InitiateLongRunningProcessInWebApi to compare request and cached assertions.
/// Always set to the assertion hash.
/// </summary>
internal string OboAssertionHash { get; set; }

/// <summary>
/// Family Refresh Tokens, can be used for all clients part of the family
/// </summary>
Expand All @@ -156,7 +162,8 @@ internal static MsalRefreshTokenCacheItem FromJObject(JObject j)
{
var item = new MsalRefreshTokenCacheItem();
item.FamilyId = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.FamilyId);
item.OboCacheKey = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.UserAssertionHash);
item.OboCacheKey = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.UserAssertionHashCacheKey);
item.OboAssertionHash = JsonHelper.ExtractExistingOrEmptyString(j, StorageJsonKeys.OboAssertionHash);

item.PopulateFieldsFromJObject(j);
item.InitCacheKey();
Expand All @@ -168,7 +175,8 @@ internal override JObject ToJObject()
{
var json = base.ToJObject();
SetItemIfValueNotNull(json, StorageJsonKeys.FamilyId, FamilyId);
SetItemIfValueNotNull(json, StorageJsonKeys.UserAssertionHash, OboCacheKey);
SetItemIfValueNotNull(json, StorageJsonKeys.UserAssertionHashCacheKey, OboCacheKey);
SetItemIfValueNotNull(json, StorageJsonKeys.OboAssertionHash, OboAssertionHash);
return json;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ internal static class StorageJsonKeys
public const string WamAccountIds = "wam_account_ids";

// todo(cache): this needs to be added to the spec. needed for OBO flow on .NET.
public const string UserAssertionHash = "user_assertion_hash";
public const string UserAssertionHashCacheKey = "user_assertion_hash";
public const string OboAssertionHash = "user_assertion_hash_only";

// previous versions of MSAL used "ext_expires_on" instead of the correct "extended_expires_on".
// this is here for back compatibility
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,6 @@ protected override async Task<AuthenticationResult> ExecuteAsync(CancellationTok

CacheRefreshReason cacheInfoTelemetry = CacheRefreshReason.NotApplicable;

//Check if initiating a long running process
if (AuthenticationRequestParameters.ApiId == ApiEvent.ApiIds.InitiateLongRunningObo)
{
//Long running process should not use cached tokens
logger.Info("[OBO Request] Initiating long running process. Fetching OBO token from ESTS.");
return await FetchNewAccessTokenAsync(cancellationToken).ConfigureAwait(false);
}

if (!_onBehalfOfParameters.ForceRefresh && string.IsNullOrEmpty(AuthenticationRequestParameters.Claims))
{
// look for access token in the cache first.
Expand All @@ -64,6 +56,13 @@ protected override async Task<AuthenticationResult> ExecuteAsync(CancellationTok
cachedAccessToken = await CacheManager.FindAccessTokenAsync().ConfigureAwait(false);
}

if (AuthenticationRequestParameters.ApiId == ApiEvent.ApiIds.InitiateLongRunningObo && cachedAccessToken != null &&

@pmaytak pmaytak May 10, 2023

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently cached/serialized token items (aka legacy tokens) don't have this new assertion hash property. So there's a scenario if app is updated to this new MSAL version that checks the hash now.

An option here is to add additional check - && !string.IsNullOrEmpty(cachedAccessToken.OboAssertionHash).

  1. As-is, without the above check, InitiateObo will never match these cached legacy tokens, and will always use the passed-in assertion to get tokens from AAD, even if the cached tokens are unexpired. This actually mimics current behavior (4.51.0+).
  2. With the additional check, InitiateObo will return the valid matched legacy access tokens and, if expired, will use legacy refresh token to refresh. This mimics the 4.50.0- behavior but only for legacy tokens. But once the legacy RT is used to refresh, then new tokens will be saved with the assertion hash.

In the scenario when AAD revokes AT and RT, method 2 reintroduces the previous issue of returning revoked tokens, so devs would have to use the StopLongObo method.

AcquireLongObo is not affected since it doesn't have the assertion in the request.

A more general question, do we even want to have an assertion hash match for refresh tokens?

Thoughts @bgavrilMS, @trwalke? Maybe I missed something?

@trwalke trwalke May 11, 2023

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we even want to have an assertion hash match for refresh tokens?

The reason we discussed adding this was to prevent the scenario where someone is using initiate long running OBO over and over again from breaking after 4.51.0+. The initial implementation for this PR lead us to the realization that in order to actually do that while maintaining the logic that fixes the revocation issue is to add another check to the refresh OBO logic to check for a matching token hash. So, if we want to prevent breaking customers, we should keep the hash match for refresh tokens.

As-is, without the above check, InitiateObo will never match these cached legacy tokens, and will always use the passed-in assertion to get tokens from AAD, even if the cached tokens are unexpired. This actually mimics current behavior (4.51.0+).

I think this will only mimic the behavior of 4.51.0+ for the first iteration. So, once the user is required to sign in again, the legacy tokens should be overwritten and assertion will be cached with the new tokens. I think this is fine. The other option (#2) will require the developer to add the StopLongRunningApi method to remove the revoked tokens. That is a much more difficult state to recover from than simply asking the user to sign in again.

So, I dont think we need to add the null check. The end user experience is affected, but all they need to do is sign in again to recover and then everything will work fine.

Adding this null check will put the user in a state they cant recover from if the token are revoked without the app developer changing the app or deleting tokens manually.

Lets sync on this tomorrow @pmaytak incase I am missing something.

!AuthenticationRequestParameters.UserAssertion.AssertionHash.Equals(cachedAccessToken.OboAssertionHash, System.StringComparison.Ordinal))
{
logger.Info("[OBO request] InitiateLongRunningProcessInWebApi found cached access token with a different assertion; fetching new tokens.");
cachedAccessToken = null;
}

if (cachedAccessToken != null)
{
var cachedIdToken = await CacheManager.GetIdTokenCacheItemAsync(cachedAccessToken).ConfigureAwait(false);
Expand Down Expand Up @@ -138,15 +137,21 @@ protected override async Task<AuthenticationResult> ExecuteAsync(CancellationTok
private async Task<AuthenticationResult> RefreshRtOrFetchNewAccessTokenAsync(CancellationToken cancellationToken)
{
var logger = AuthenticationRequestParameters.RequestContext.Logger;

if (ApiEvent.IsLongRunningObo(AuthenticationRequestParameters.ApiId))
{
AuthenticationRequestParameters.RequestContext.Logger.Info("[OBO request] Long-running OBO flow, trying to refresh using a refresh token flow.");


// Look for a refresh token
MsalRefreshTokenCacheItem cachedRefreshToken = await CacheManager.FindRefreshTokenAsync().ConfigureAwait(false);

if (AuthenticationRequestParameters.ApiId == ApiEvent.ApiIds.InitiateLongRunningObo && cachedRefreshToken != null &&
!AuthenticationRequestParameters.UserAssertion.AssertionHash.Equals(cachedRefreshToken.OboAssertionHash, System.StringComparison.Ordinal))
{
logger.Info("[OBO request] InitiateLongRunningProcessInWebApi found cached refresh token with a different assertion; fetching new tokens.");
cachedRefreshToken = null;
}

// If a refresh token is not found, fetch a new access token
if (cachedRefreshToken != null)
{
Expand Down Expand Up @@ -176,11 +181,11 @@ private async Task<AuthenticationResult> RefreshRtOrFetchNewAccessTokenAsync(Can
throw new MsalClientException(MsalError.OboCacheKeyNotInCacheError, MsalErrorMessage.OboCacheKeyNotInCache);
}

AuthenticationRequestParameters.RequestContext.Logger.Info("[OBO request] No Refresh Token was found in the cache. Fetching OBO token from ESTS");
AuthenticationRequestParameters.RequestContext.Logger.Info("[OBO request] No refresh token was found in the cache. Fetching OBO tokens from ESTS.");
}
else
{
logger.Info("[OBO request] Normal OBO flow, skipping to fetching access token via OBO flow.");
logger.Info("[OBO request] Fetching tokens via normal OBO flow.");
}

return await FetchNewAccessTokenAsync(cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ async Task<Tuple<MsalAccessTokenCacheItem, MsalIdTokenCacheItem, Account>> IToke

string suggestedWebCacheKey = CacheKeyFactory.GetExternalCacheKeyFromResponse(requestParams, homeAccountId);

// token could be comming from a different cloud than the one configured
// token could be coming from a different cloud than the one configured
if (requestParams.AppConfig.MultiCloudSupportEnabled && !string.IsNullOrEmpty(response.AuthorityUrl))
{
var url = new Uri(response.AuthorityUrl);
Expand All @@ -79,7 +79,8 @@ async Task<Tuple<MsalAccessTokenCacheItem, MsalIdTokenCacheItem, Account>> IToke
tenantId,
homeAccountId,
requestParams.AuthenticationScheme.KeyId,
CacheKeyFactory.GetOboKey(requestParams.LongRunningOboCacheKey, requestParams.UserAssertion));
CacheKeyFactory.GetOboKey(requestParams.LongRunningOboCacheKey, requestParams.UserAssertion),
requestParams.UserAssertion?.AssertionHash);
}

if (!string.IsNullOrEmpty(response.RefreshToken))
Expand All @@ -100,6 +101,7 @@ async Task<Tuple<MsalAccessTokenCacheItem, MsalIdTokenCacheItem, Account>> IToke
homeAccountId)
{
OboCacheKey = CacheKeyFactory.GetOboKey(requestParams.LongRunningOboCacheKey, requestParams.UserAssertion),
OboAssertionHash = requestParams.UserAssertion?.AssertionHash,
};

if (!_featureFlags.IsFociEnabled)
Expand Down
14 changes: 7 additions & 7 deletions tests/Microsoft.Identity.Test.Common/Core/Mocks/MockHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,12 @@ public static string GetFociTokenResponse()
"\"trace_id\":\"dd25f4fb-3e8d-458e-90e7-179524ce0000\",\"correlation_id\":" +
"\"f11508ab-067f-40d4-83cb-ccc67bf57e45\"}";

public static string GetDefaultTokenResponse(string accessToken = TestConstants.ATSecret)
public static string GetDefaultTokenResponse(string accessToken = TestConstants.ATSecret, string refreshToken = TestConstants.RTSecret)
{
return
"{\"token_type\":\"Bearer\",\"expires_in\":\"3599\",\"refresh_in\":\"2400\",\"scope\":" +
"\"r1/scope1 r1/scope2\",\"access_token\":\"" + accessToken + "\"" +
",\"refresh_token\":\"" + Guid.NewGuid() + "\",\"client_info\"" +
",\"refresh_token\":\"" + refreshToken + "\",\"client_info\"" +
":\"" + CreateClientInfo() + "\",\"id_token\"" +
":\"" + CreateIdToken(TestConstants.UniqueId, TestConstants.DisplayableId) + "\"}";
}
Expand All @@ -69,7 +69,7 @@ public static string GetPopTokenResponse()
return
"{\"token_type\":\"pop\",\"expires_in\":\"3599\",\"scope\":" +
"\"r1/scope1 r1/scope2\",\"access_token\":\"" + TestConstants.ATSecret + "\"" +
",\"refresh_token\":\"" + Guid.NewGuid() + "\",\"client_info\"" +
",\"refresh_token\":\"" + TestConstants.RTSecret + "\",\"client_info\"" +
":\"" + CreateClientInfo() + "\",\"id_token\"" +
":\"" + CreateIdToken(TestConstants.UniqueId, TestConstants.DisplayableId) +
"\",\"id_token_expires_in\":\"3600\"}";
Expand All @@ -80,7 +80,7 @@ public static string GetHybridSpaTokenResponse(string spaCode)
return
"{\"token_type\":\"Bearer\",\"expires_in\":\"3599\",\"refresh_in\":\"2400\",\"scope\":" +
"\"r1/scope1 r1/scope2\",\"access_token\":\"" + TestConstants.ATSecret + "\"" +
",\"refresh_token\":\"" + Guid.NewGuid() + "\",\"client_info\"" +
",\"refresh_token\":\"" + TestConstants.RTSecret + "\",\"client_info\"" +
":\"" + CreateClientInfo() + "\",\"id_token\"" +
":\"" + CreateIdToken(TestConstants.UniqueId, TestConstants.DisplayableId) +
"\",\"spa_code\":\"" + spaCode + "\"" +
Expand All @@ -92,7 +92,7 @@ public static string GetBridgedHybridSpaTokenResponse(string spaAccountId)
return
"{\"token_type\":\"Bearer\",\"expires_in\":\"3599\",\"refresh_in\":\"2400\",\"scope\":" +
"\"r1/scope1 r1/scope2\",\"access_token\":\"" + TestConstants.ATSecret + "\"" +
",\"refresh_token\":\"" + Guid.NewGuid() + "\",\"client_info\"" +
",\"refresh_token\":\"" + TestConstants.RTSecret + "\",\"client_info\"" +
":\"" + CreateClientInfo() + "\",\"id_token\"" +
":\"" + CreateIdToken(TestConstants.UniqueId, TestConstants.DisplayableId) +
"\",\"spa_accountId\":\"" + spaAccountId + "\"" +
Expand Down Expand Up @@ -193,10 +193,10 @@ public static HttpResponseMessage CreateSuccessTokenResponseMessage(
scopes, idToken, clientInfo));
}

public static HttpResponseMessage CreateSuccessTokenResponseMessage(bool foci = false, string accessToken = TestConstants.ATSecret)
public static HttpResponseMessage CreateSuccessTokenResponseMessage(bool foci = false, string accessToken = TestConstants.ATSecret, string refreshToken = TestConstants.RTSecret)
{
return CreateSuccessResponseMessage(
foci ? GetFociTokenResponse() : GetDefaultTokenResponse(accessToken));
foci ? GetFociTokenResponse() : GetDefaultTokenResponse(accessToken, refreshToken));
}

public static HttpResponseMessage CreateSuccessTokenResponseMessageWithUid(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ internal static MsalRefreshTokenCacheItem CreateRefreshTokenItem(
Environment = TestConstants.ProductionPrefCacheEnvironment,
HomeAccountId = homeAccountId,
OboCacheKey = oboCacheKey,
OboAssertionHash = TestConstants.UserAssertion,
Secret = refreshToken,
};

Expand Down Expand Up @@ -173,7 +174,8 @@ internal static void PopulateCache(
extendedAccessTokenExpiresOn,
clientInfo,
homeAccId,
oboCacheKey: userAssertionHash);
oboCacheKey: userAssertionHash,
oboAssertionHash: userAssertionHash);

// add access token
accessor.SaveAccessToken(atItem);
Expand Down Expand Up @@ -385,7 +387,7 @@ public static void ExpireAccessToken(ITokenCacheInternal tokenCache, MsalAccessT

public static MsalAccessTokenCacheItem WithRefreshOn(this MsalAccessTokenCacheItem atItem, DateTimeOffset? refreshOn)
{
MsalAccessTokenCacheItem newAtItem = new MsalAccessTokenCacheItem(
return new MsalAccessTokenCacheItem(
atItem.Environment,
atItem.ClientId,
atItem.ScopeString,
Expand All @@ -399,14 +401,13 @@ public static MsalAccessTokenCacheItem WithRefreshOn(this MsalAccessTokenCacheIt
atItem.KeyId,
refreshOn,
atItem.TokenType,
atItem.OboCacheKey);

return newAtItem;
atItem.OboCacheKey,
atItem.OboAssertionHash);
}

public static MsalAccessTokenCacheItem WithUserAssertion(this MsalAccessTokenCacheItem atItem, string assertion)
{
MsalAccessTokenCacheItem newAtItem = new MsalAccessTokenCacheItem(
return new MsalAccessTokenCacheItem(
atItem.Environment,
atItem.ClientId,
atItem.ScopeString,
Expand All @@ -420,9 +421,8 @@ public static MsalAccessTokenCacheItem WithUserAssertion(this MsalAccessTokenCac
atItem.KeyId,
atItem.RefreshOn,
atItem.TokenType,
assertion,
assertion);

return newAtItem;
}

public static void UpdateUserAssertions(ConfidentialClientApplication app)
Expand Down Expand Up @@ -450,6 +450,7 @@ public static void UpdateRefreshTokenUserAssertions(ITokenCacheInternal tokenCac
foreach (var rtItem in rtItems)
{
rtItem.OboCacheKey = assertion;
rtItem.OboAssertionHash = assertion;
tokenCache.Accessor.SaveRefreshToken(rtItem);
}
}
Expand Down
Loading