microsoft-authentication-library-for-dotnet icon indicating copy to clipboard operation
microsoft-authentication-library-for-dotnet copied to clipboard

[Bug] AcquireTokenSilent silently discards MFA claim?

Open S-dn-Y opened this issue 1 year ago • 4 comments

Library version used

4.61.3.0

.NET version

4.8.04161

Scenario

PublicClient - desktop app

Is this a new or an existing app?

This is a new app or experiment

Issue description and reproduction steps

Hi everyone,

I'm integrating the MSAL library into our PowerShell code and I've observed that AcquireTokenSilent seems to silently discard MFA claims. I'm adding the extra query parameters to the request, which are applied as shown in the logs. The application is new, also as shown in the logs. The token is provided without issues, but if I check the actual token using https://jwt.ms/, the amr claim is not in there, only the pwd and rsa values for amr. Using the token results in MFA issues of course. The doesn't seem to comply with what the docs are saying:

#Exceptions MsalUiRequiredException will be thrown in the case where an interaction is required with the end user of the application, for instance, if no refresh token was in the cache, or the user needs to consent, or re-sign-in (for instance if the password expired), or the user needs to perform two factor authentication

Since the recommended pattern is to first try to achieve a token silently from cache before starting an interactive flow, shouldn't AcquireTokenSilent throw an exception of type Microsoft.Identity.Client.MsalUiRequiredException allowing the user to complete the interactive flow?

Relevant log entries: ###> New application nothing found in cache yet

  • [GetAccounts] Found 0 RTs and 0 accounts in MSAL cache.

###> Query parameters are added to the request

  • [2024-08-26 18:38:46Z] [MSAL:0005] INFO ModifyAndValidateAuthParameters:219 Additional query parameter added successfully. Key: 'claims' Value: '{"access_token" : {"amr": { "values": ["mfa"] }}}'

Is this expected behaviour? I've added some of the code I'm using the reproduce it. Note that the same code is used in the interactive flow and the user is challenged for MFA and the claim is given. Now I don't expect that the silent flow is able to complete the MFA challenge, but if MSAL silently discards the claim, how would I know that a cached token has the claim? Especially since decoding the tokens on the client isn't best practice (if I recall reading it correctly).

Hoping someone can help! Any effort is appreciated.

Best regards, Sidney

Relevant code snippets

Import-Module -Name Az.Accounts -MinimumVersion 3.0.3 # Loads mentioned MSAL.NET
[String]$ClientId = '<clientId>' # ClientId of App Registration with some graph permissions applied
[String]$RedirectUri = 'http://localhost'
[String]$Authority = 'https://login.microsoftonline.com/<tentantId>'
[Bool]$ValidateAuthority = $false
[System.Collections.Arraylist]$Scopes = [System.Collections.Arraylist]::new(@('https://graph.microsoft.com/.default'))

# Create PublicClientApplicationBuilder object and add requested parameters
[Microsoft.Identity.Client.PublicClientApplicationBuilder]$appBuilder = [Microsoft.Identity.Client.PublicClientApplicationBuilder]::Create($ClientId)
$appBuilder.WithRedirectUri($RedirectUri) | Out-Null
$appBuilder.WithAuthority($Authority, $ValidateAuthority) | Out-Null

# Add the WAM broker (recommended for interactive authentication on Windows platforms)
[Microsoft.Identity.Client.BrokerOptions]$brokerOptions = [Microsoft.Identity.Client.BrokerOptions]::new("Windows")
[Microsoft.Identity.Client.Broker.BrokerExtension]::WithBroker($appBuilder, $brokerOptions) | Out-Null
# Now build the application
[Microsoft.Identity.Client.IPublicClientApplication]$app = $appBuilder.Build()
Write-Verbose "Done." -Verbose

# Add requested scopes to a generic list
[Collections.Generic.List[string]]$scopeList = [Collections.Generic.List[string]]::new()
foreach ($scope in $Scopes) {
   $scopeList.Add($scope) | Out-Null
}

# If the application was already logged into we'll use the account from cache
[Microsoft.Identity.Client.IAccount]$loginAccount = $app.GetAccountsAsync().GetAwaiter().GetResult() | Select-Object -First 1

# And if the account wasn't found we'll use the currently logged in user
if (-not($loginAccount)) {

    # [Microsoft.Identity.Client.PublicClientApplication]::OperatingSystemAccount represent the currently logged in account
    $loginAccount = [Microsoft.Identity.Client.PublicClientApplication]::OperatingSystemAccount
    Write-Verbose "Account wasn't found in cache, using currently logged in user." -Verbose
}
else {
                            
    Write-Verbose "Found account in cache." -Verbose
}

# Now construct parameter builder
[Microsoft.Identity.Client.AcquireTokenSilentParameterBuilder]$tokenRequest = $app.AcquireTokenSilent($scopeList, $loginAccount)

# Add MFA claim
[System.Collections.Generic.Dictionary[[string],[string]]]$extraQueryParams = [System.Collections.Generic.Dictionary[[string],[string]]]::new()
$extraQueryParams.Add('claims','{"access_token" : {"amr": { "values": ["mfa"] }}}')
$tokenRequest.WithExtraQueryParameters($extraQueryParams) | Out-Null

Write-Verbose "Trying to acquire a token silently." -Verbose
Write-Verbose "Request will time out after 20 seconds." -Verbose
[System.Threading.CancellationTokenSource]$cancellationTokenSourceSilent = [System.Threading.CancellationTokenSource]::new(20000) # TimeOut in milliseconds
[System.Threading.CancellationToken]$cancellationTokenSilent = $cancellationTokenSourceSilent.Token
$cancellationTokenSilent.ThrowIfCancellationRequested()
[Microsoft.Identity.Client.AuthenticationResult]$authenticationResult = $null
$authenticationResult = $tokenRequest.ExecuteAsync($cancellationTokenSilent).GetAwaiter().GetResult()
Write-Verbose "Succesfully retrieved token." -Verbose

Expected behavior

I would expect that AcquireTokenSilent throws an MsalUiRequiredException, instead of requesting the token without the MFA claim in the result.

Identity provider

Microsoft Entra ID (Work and School accounts and Personal Microsoft accounts)

Regression

No response

Solution and workarounds

I'm now removing the application from the cache if any error happens while requesting a token, so I know that every application in cache that has the MFA claim will have the MFA claim after silently acquiring it from the cache.

S-dn-Y avatar Aug 26 '24 19:08 S-dn-Y

Does this code work if you don't use WAM?

I am not familiar with adding a claims challenge for forcing MFA. Is this documented anywhere? Afaik, the STS is responsible for enforcing MFA through Conditional Access policies.

bgavrilMS avatar Aug 28 '24 09:08 bgavrilMS

Hi @bgavrilMS,

Thanks for the reply. Interesting view, honestly I'm not sure, I found this method in several modules on the PowerShell Gallery and online resources, but no MS owned ones as far as I know (for example, search for ExtraQueryParameters shows the usage):

  • https://www.powershellgallery.com/packages/DCToolbox/1.0.24/Content/DCToolbox.psm1
  • https://www.powershellgallery.com/packages/PIMTools/0.6.1.0/Content/functions%5CNew-AzureADPIMRequest.ps1

As for without the WAM broker, good point. I'm now using the code in production without the WAM broker (yet) and the silent method fails regardless if the account is not cached yet, as there's nothing to silently acquire. With WAM this is different as WAM is able to silently sign in the user, without having a cached application. Expanded my test a bit to cache the application (using the interactive flow) first without the ExtraQueryParameters added and then try to silently acquire it, with the ExtraQueryParameters as sepicfied in the code added. All without WAM involved.

Same behaviour though, the token is granted through the interactive flow, without being challenged with MFA, then the application and token is cached and the interactive flow (with the MFA claim added in the ExtraQueryParameters) acquires the token without asking for anything, but ignores that MFA claim.

Hopefully this helps. Please let me know if there's anything I can help you with.

Best regards, Sidney

S-dn-Y avatar Aug 28 '24 09:08 S-dn-Y

Well MSAL does have a "WithClaims" API, which is probably better used here as it will affect the communication with both /authorization and /token endpoint. But it'll also bypass the cache, so it should not be used after the first login.

bgavrilMS avatar Aug 28 '24 09:08 bgavrilMS

Thank you @bgavrilMS. Reading in on the method, also tried converting my ExtraQueryParameters to a WithClaims method: $tokenRequestSilent.WithClaims('{"access_token" : {"amr": { "values": ["mfa"] }}}') | Out-Null also tried $tokenRequestSilent.WithClaims('{"amr": { "values": ["mfa"] }}') | Out-Null Doesn't seem to do the trick though, still ignored. The docs point out that I would want to add any claims that are returned with the error to this method while calling AcquireTokenInteractive (eg $tokenRequestInteractive.WithClaims($exception.Claims)), but I'm not getting an error as MFA is not enforced on my account but enforced on specific endpoints/resources I sign in with the token. I may be using the WithClaims string incorrectly but wasn't able to find any examples as of now on how it should be formatted.

Thanks again!! Best regards, Sidney

S-dn-Y avatar Aug 28 '24 10:08 S-dn-Y

Hi @S-dn-Y , have you configured the account with MFA? Here is the doc https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa?toc=%2Fentra%2Fidentity%2Fconditional-access%2Ftoc.json&bc=%2Fentra%2Fidentity%2Fconditional-access%2Fbreadcrumb%2Ftoc.json, could you help confirm and which resource you added the MFA policy?

Note that the same code is used in the interactive flow and the user is challenged for MFA and the claim is given.

so previously the interactive call is challenged with claims

Same behaviour though, the token is granted through the interactive flow, without being challenged with MFA,

but later the interactive flow doesn't challenge you MFA? Did you change anything?

I am trying to understand what you want to achieve, please correct me if I am wrong. Do you want both interactive and silent flow challenged with MFA with the specific resource? Could you provide the correlation id and timestamp so that we can check what's the issue? Thanks.

xinyuxu1026 avatar Aug 29 '24 23:08 xinyuxu1026

Hi @xinyuxu1026,

Thank you chipping in. I may have left some information out on how I'm trying to use it exactly, let me try to elaborate :)

I'm trying to activate Eligible Microsoft Entra Privileged Identity Management (PIM) roles through code, PowerShell. To do so, I've created some PowerShell functions around Get-AzRoleEligibilitySchedule and New-AzRoleAssignmentScheduleRequest (for Azure Resource roles) and created some custom functions around the Graph REST API (for Entra ID roles) because the Graph module needed for activating them loaded painfully slow.

I've created a Service Principal in Azure with delegated permissions needed to query and activate the roles. I then use the Service Principal as Client Id in my MSAL code to request a token and thus get a token with the needed scopes added. Activated by either using 'https://graph.microsoft.com/.default' for Entra roles to get all Graph Scopes assigned to my Service principal or 'https://management.azure.com/user_impersonation' to get the needed rights for Azure Resources (but I only activate one per time since of course since combining the both resources is not supported).

So when I want to enable a Entra role the flow would look like this:

  1. Invoke function, Get-GPIMEntraIDRoleEligibleAssigment
  2. Function Get-GPIMEntraIDRoleEligibleAssigment invokes my MSAL function of which snippets were added to get a token using the mentioned Service Principal.
  3. Since retrieving eligible roles doesn't require MFA on the Entra ID side the token is requested without the ExtraQueryParameters for MFA added.
  4. The token is requested using the AcquireTokenSilent method and it succeeds cause the user is trying to sign in to the same tenant the device is Hybrid Joined into and the MSAL function uses WAM.
  5. The user gets a menu presented with eligible Entra roles and selects one which invokes another function: Enable-GPIMEntraIDRoleEligibleAssigment.
  6. Since I'm not sure if the role the user tries to activate requires an MFA challenge I request another MSAL token with the mentioned ExtraQueryParameters added.
  7. The MSAL function finds the application in cache and uses it, adds the ExtraQueryParameters to the AcquireTokenSilent method and invokes it.
  8. The AcquireTokenSilent method succeeds, the token is returned and used to activate the role against the Graph REST API. This fails however because the token doesn't include the MFA claim. Which https://jwt.ms/ acknowledges if I take a peek in the token.

Note that if I modify the flow and feed the ExtraQueryParameters to AcquireTokenInteractive (and thus skip the first AcquireTokenSilent) it prompts for credentials and challenges the user for MFA and the claim is added. The functions will then work without problems as the MFA claim is present. Also subsequent call to AcquireTokenSilent will work because the MFA claim was added during my first call. I would, however, expect AcquireTokenSilent to throw an error if it cannot silently provide a token that fulfils the extra query parameters.

I've just completed the flow as described. Step 1-4 (quering roles without MFA ExtraQueryParameters): CorrelationId: 9c41d4cc-e154-4eaf-91dd-b89517239827 (the console with the log got truncated by screen resizing before I got to capture the timestamp, somewhat before the time mentioned below) Step 5-8 (Activating role with MFA ExtraQueryParameters): CorrelationId: 7ecbf69a-fd3c-489e-bef1-69c6140d4a37 - Timestamp: 2024-08-30 08:05:05Z

Error triggered: Could not activate the 'Application Administrator' PIM role: {"error":{"code":"RoleAssignmentRequestPolicyValidationFailed","message":"The following policy rules failed: ["MfaRule"]",...

Hope this helps and thanks again! Best regards, Sidney

S-dn-Y avatar Aug 30 '24 08:08 S-dn-Y

@S-dn-Y , for account not configured with MFA, the silent request discard MFA is by design.

xinyuxu1026 avatar Sep 05 '24 20:09 xinyuxu1026

Hi @S-dn-Y , if the claim is not issued by ESTS or by a resource, then this behavior is expected. Customers should not be injecting magic values themselves as that approach is extremely brittle and can result in unintended consequences down the road.

For PIM issue, please refer to this documentation for the error https://learn.microsoft.com/en-us/rest/api/authorization/includes/privileged-role-common-errors. Thanks

xinyuxu1026 avatar Sep 26 '24 17:09 xinyuxu1026

Hi @S-dn-Y , if the claim is not issued by ESTS or by a resource, then this behavior is expected. Customers should not be injecting magic values themselves as that approach is extremely brittle and can result in unintended consequences down the road.

Thank you for the help here @xinyuxu1026 and @bgavrilMS. Sorry for the late reply on this, was on holiday and this got lost in my inbox. If this is by design it's indeed not a bug indeed, so this should indeed be closed/completed.

I have been digging a bit more and came up with another plan. Instead of injecting magic values I'm planning on using Conditional Access on the Service Principal I use. However, I do not want to challenge every user that requests a token every time, as not every resource requires a token with MFA and this could break the silent flow with WAM (if MFA wasn't requested/required but enforced by Condition Access). I'm planning on investigating Conditional Access Authentication Context for this to see if I can have Conditional Access enforce MFA only if needed or requested.

Not sure if this is the way it should work, if not then any guidance on how it should work is very appreciated. A lot of resources/documentation just states that MFA must be completed (like this documentation mentioned: https://learn.microsoft.com/en-us/rest/api/authorization/includes/privileged-role-common-errors) but doesn't go into detail on how one would obtain a token that is challenged with MFA.

Thanks again! Best regards, Sidney

S-dn-Y avatar Sep 30 '24 12:09 S-dn-Y

Reopening to continue the conversation.

@S-dn-Y - let's assume this scenario:

  1. You request a token for resource R1. There is no RT in the cache, so interactive flow is needed. R1 doesn't require MFA, so it is not requested.
  2. You request a token for resource R2, which also doesn't require MFA. This works, because the existing refresh token is enough to get an access token for R2.
  3. You request a token for R3, but R3 requires MFA.

Expected: you get an MsalUiRequiredException, possibly with Claims property. Interactive auth for R3 will ask the user to complete MFA.

Do you see any experience issue with this flow?

With regards to CA Auth Context - this is a more advanced feature that enables you to trigger a CA policy explicitly from the application. Typical example is displaying some data to a normal user vs displaying more data to a super user. It works by checking the Id Token has a special claim corresponding to a CA Auth Context. If it doesn't, then you create a small claim challenge yourself (smth like ca_auth_context id = 14 and challenge the user. Entra ID will trigger the CA associated with that id ID, for example MFA.

It seems like a convoluted way to force MFA. Wouldn't it be simpler to enforce MFA (via Conditional Access) to all resources (security win!)

bgavrilMS avatar Sep 30 '24 14:09 bgavrilMS

Thank you @bgavrilMS, your help is greatly appreciated!!

Do you see any experience issue with this flow?

Hahah well, te be very honest.. I don't. But I have the feeling that there is an issue here ;)

It seems like a convoluted way to force MFA. Wouldn't it be simpler to enforce MFA (via Conditional Access) to all resources (security win!)

I think you are right, but.. The thing is, I'm trying to allow the user to request a token without bugging them with MFA each time (at least each time the token is cleared from the cache..). Because, for example, the API for getting a list of eligible role assignments in Entra ID doesn't require you to do MFA. Only when you're actually activating the eligible assignments some of the roles require MFA.

My PowerShell script to generate MSAL tokens in a way acts as the application here, and when the user adds the -ChallengeMFA switch the application should instruct CA to challenge the user. My idea was to set a CA Auth Context if the user added -ChallengeMFA and which signals CA to enforce it. But in the end this may indeed complicate things.. I guess it may be easier to create 2 App Registrations; 1 that is challenged for MFA by CA, 1 that isn't. So I know that any token requested through the MFA enforced App Registration will always be challenged for MFA and I can safely use that against a resource that requires it.

Wish I could clone a few of your brains to take a better peak at things hahah, very interesting stuff. I feel like the MSAL .NET docs don't go into that much detail, like the reason why the magic values are discarded during the silent flow. Again.. I may be looking in the wrong places :)

Thanks again! Best regards, Sidney

S-dn-Y avatar Sep 30 '24 19:09 S-dn-Y

Hi Sidney,

When you say I'm trying to allow the user to request a token without bugging them with MFA each time (at least each time the token is cleared from the cache..). - can you go in more detail? Our goal is to minimize the number of user prompts...ideally there will only be 1 prompt, per device, per user.

Once MFA is complete, ESTS issues a refresh token with mfa=1. You should never be prompted again for MFA because of it. Either MSAL or WAM will cache that refresh token. The refresh token's lifetime is 3+ months, and it gets updated on every refresh too. So as long as the user logs in at least once in 3 months, they should not be prompted again. So I don't understand the statement "prompting them to MFA each time". Maybe you are not serializing the cache? https://learn.microsoft.com/en-us/entra/msal/dotnet/how-to/token-cache-serialization?tabs=desktop

CA policy, afaik, isn't granular enough to trigger when the user requests a token for scope1 vs scope2 of a certain resource. I may be wrong, but this is where you can use CA auth context.

2 app registrations also seems strange. The login logs will be confusing and you'll have 2 id tokens too. Imagine a situation where a user logs in with user1 for clientId1 and with a different user for clientID2. You can throw an error by checking idToken.OID claim, but still.....

PS: The "magic values" are not supported by the v2 endpoint of AAD, which MSAL uses. Someone probably copied them from the older ADAL library, saw that they work with "interactive" flow, but never tested with "silent" flow :).

bgavrilMS avatar Sep 30 '24 20:09 bgavrilMS

Hi Bogdan,

Wow.. it hit me yesterday after reading your reply.

Maybe you are not serializing the cache? https://learn.microsoft.com/en-us/entra/msal/dotnet/how-to/token-cache-serialization?tabs=desktop

I wasn't indeed.. It makes perfect sense now, I was mainly trying to avoid users having to do MFA every time they close the shell, but I was indeed using a variable as cache, which would get wiped on shell restart. I have implemented a token cache in the user's AppData now so that it roams between hosts together with the user (remote app scenario) and while I haven't fully tested roaming etc, I was able to successfully test the cache in my code.

What confused me is the fact that you first have to create a new Public Client Application and it somehow matches that on an existing cache if you hook the cache into that new application. I was initially searching the cache living in the variable to see if it already existed, but after implementing the actual cache on disk it works very well. This will make the user experience even better than I hoped hahah.

PS: The "magic values" are not supported by the v2 endpoint of AAD, which MSAL uses. Someone probably copied them from the older ADAL library, saw that they work with "interactive" flow, but never tested with "silent" flow :).

Ah, that makes sense. Even more reason to stop using it if someone decides to tidy up the v2's endpoint code :)

Thank you very much for the time invested here. Noticed your username in the screenshots in articles about the token cache, good stuff!

While out of scope and likely not under your (team's) responsibility, I hope at one point Microsoft releases a fully supported MSAL PowerShell module, feel like a lot of us would benefit from it and it would also solve other things like clashing DLL's. It can be a pain working with for example a mixture of the Az, Microsoft365DSC and/or Microsoft.Graph modules as they all ship with their own MSAL .NET DLL's (as far as I can tell). I now have Az.Account as a required module so all MSAL .NET code get's loaded, but still notice some issues with the mentioned modules depending on the order they load.

Anyways, it was a pleasure! I'll close this case again because the work is done. Have a great day ahead :)

Best regards, Sidney

S-dn-Y avatar Oct 01 '24 12:10 S-dn-Y

Thanks for the follow up and for the feedback on PowerShell. CC @localden for the scenarios.

bgavrilMS avatar Oct 01 '24 13:10 bgavrilMS