dotnet/aspnetcore

MSAL on Blazor WebAssembly refresh token expiration invalid_grant regression

dino182 opened this issue ยท 22 comments

Is there an existing issue for this?

  • I have searched the existing issues

Describe the bug

The fix for #28151 in #28498 appears to have been regressed by the c1d16e3#diff-e93d88646a9c7b8accad9fd0d381af964d3f5e6bba9d4e72c22f602565a3c4cc commit. This impacts the 7.x versions of the Microsoft.Authentication.WebAssembly.Msal. The 6.x versions still contain the previous fix.
The bug is exactly as described in #28151. The only difference is that the error message returned from the failed POST request to the token endpoint is:

"error":"invalid_grant"
"error_description":"AADSTS700084: The refresh token was issued to a single page app (SPA), and therefore has a fixed, limited lifetime of 1.00:00:00, which cannot be extended. It is now expired and a new sign in request must be sent by the SPA to the sign in page. The token was issued on 2023-05-15T08:46:39.6945713Z.\r\nTrace ID: 002abb82-c8d5-4519-8340-3b1f3bc70000\r\nCorrelation ID: cda6cada-7268-4328-9ae6-4c7dac1d8b54\r\nTimestamp: 2023-05-16 19:43:43Z"

Expected Behavior

The AuthenticationService should handle this exception and automatically trigger a new sign in request. It should not result in an unhandled error bubbling up to the Blazor app.

Steps To Reproduce

  • Create a typical Blazor + MSAL application using local storage as the token cache
  • Trigger a sign in and extract the tokens from the developer tools window
  • Wait 24 hours to allow the refresh token to expire
  • Paste the extracted tokens back into local storage if these are replaced
  • Restart the application

Exceptions (if any)

No response

.NET Version

7.0.203

Anything else?

.NET SDK:
 Version:   7.0.203
 Commit:    5b005c19f5

Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.22621
 OS Platform: Windows
 RID:         win10-x64
 Base Path:   C:\Program Files\dotnet\sdk\7.0.203\

Host:
  Version:      7.0.5
  Architecture: x64
  Commit:       8042d61b17

.NET SDKs installed:
  6.0.202 [C:\Program Files\dotnet\sdk]
  7.0.203 [C:\Program Files\dotnet\sdk]

.NET runtimes installed:
  Microsoft.AspNetCore.App 6.0.5 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 6.0.8 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 6.0.11 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 6.0.12 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 6.0.16 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.AspNetCore.App 7.0.5 [C:\Program Files\dotnet\shared\Microsoft.AspNetCore.App]
  Microsoft.NETCore.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.4 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.5 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.8 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.11 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.12 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.14 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.15 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 6.0.16 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.NETCore.App 7.0.5 [C:\Program Files\dotnet\shared\Microsoft.NETCore.App]
  Microsoft.WindowsDesktop.App 5.0.17 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.4 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.5 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.8 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.11 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.12 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 6.0.16 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]
  Microsoft.WindowsDesktop.App 7.0.5 [C:\Program Files\dotnet\shared\Microsoft.WindowsDesktop.App]

Other architectures found:
  x86   [C:\Program Files (x86)\dotnet]
    registered at [HKLM\SOFTWARE\dotnet\Setup\InstalledVersions\x86\InstallLocation]

Environment variables:
  Not set

global.json file:
  Not found

Learn more:
  https://aka.ms/dotnet/info

Download .NET:
  https://aka.ms/dotnet/download

To learn more about what this message means, what to expect next, and how this issue will be handled you can read our Triage Process document.
We're moving this issue to the .NET 8 Planning milestone for future evaluation / consideration. Because it's not immediately obvious what is causing this behavior, we would like to keep this around to collect more feedback, which can later help us determine how to handle this. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact work.

We started to get the same issue recently after updating some libraries (.NET from 7.0.5 to 7.0.8) and Microsoft.Identity.Web from 2.11.1 to 2.12.4 (but it's rather related to .NET upgrade only). In 7.0.5 version new tokens were requested automatically (MS sign in page was visible for a moment) in 7.0.8 it ends up with error from the description.
App is Blazor WASM (hosted) and uses MSAL.

We're having the same problem in Microsoft.Authentication.WebAssembly.Msal 7.0.0.

We see the mentioned AADSTS700084 error for the expired token as well as AADSTS50078 for an MFA expiry.

The try catch in the getUser() method was removed in this commit: 3ac7ee7#diff-e93d88646a9c7b8accad9fd0d381af964d3f5e6bba9d4e72c22f602565a3c4ccL103

Same problem in Microsoft.Authentication.WebAssembly.Msal 7.0.10

To learn more about what this message means, what to expect next, and how this issue will be handled you can read our Triage Process document.
We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. Because it's not immediately obvious what is causing this behavior, we would like to keep this around to collect more feedback, which can later help us determine how to handle this. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact work.

Dont understand that this is being moved to .NET 9. This is a crucial feature for Blazor with MS authentication. I can't upgrade any of my projects because of this. Just restore it to how it was implemented in .NET 6 @javiercn

Is there any workaround for this, how can we catch the exception and redirect to azure login?

Hi. We have a couple of web applications that used to work seamlessly authentication side, using the standard MSAL mechanism. Originally, both apps were developed using .NET 6. Recently we upgraded to .NET 8 and consequently we upgraded all the libraries, and there started the issue described here.
At the moment, we have a strange behavior where there are users with no issues, while others (myself too) are experiencing the same issue as other colleagues over here: they already have access token in the browser local storage, but it's expired and the mechanism of refresh doesn't work anymore.
So they are stuck, and in the console we can see an error saying that the API call to get the token failed. This is the response:

{
"error": "invalid_grant",
"error_description": "AADSTS700084: The refresh token was issued to a single page app (SPA), and therefore has a fixed, limited lifetime of 1.00:00:00, which cannot be extended. It is now expired and a new sign in request must be sent by the SPA to the sign in page. The token was issued on 2023-12-18T09:49:15.0104937Z. Trace ID: 56783d5b-15d1-4dc8-be60-5caf27519200 Correlation ID: 5b7629f9-e56e-4965-8d32-04517f4eeea5 Timestamp: 2023-12-19 14:55:27Z",
"error_codes": [
700084
],
"timestamp": "2023-12-19 14:55:27Z",
"trace_id": "56783d5b-15d1-4dc8-be60-5caf27519200",
"correlation_id": "5b7629f9-e56e-4965-8d32-04517f4eeea5",
"error_uri": "https://login.microsoftonline.com/error?code=700084",
"suberror": "bad_token"
}

Any suggestion to get it fixed?

We'll consider patching this for both 7.0.x and 8.0.x.
Let's start from investigating this first. @halter73 it's all yours. Thanks!

Is there any temporary workaround we can apply? Like, catch the exception and manually trigger a sign-in request? We aggressively expire refresh tokens, so this breaks our apps.

I need this too. Cannot ship any application with this error or at least a sufficient work around.

Had this while upgrading projects to .NET 8 from 6 (with the MS packages tracking the framework version).

Workaround for me was to revert the packages Microsoft.Authentication.WebAssembly.Msal and Microsoft.AspNetCore.Components.WebAssembly.Authentication to their 6.x (6.0.26) versions until this can be resolved in later versions. Not 100% sure if both packages needed to be reverted, but I still had some issues when I just reverted MSAL.

So, is anything actually happening on this? This is a major issue for us as well. Shipping is a no-go with this bug, and rolling packages back two major versions hurts.

A failed POST to the /token endpoint producing a 400 status with a AADSTS700084 error in the response is expected in some circumstances. You should not be alarmed to see this in your network logs.

Refresh tokens have a 24 hour lifetime. Once expired MSAL will attempt to silently acquire a a new auth code and then redeem for a fresh set of access, id and refresh tokens. This fallback fails in Safari as it depends on 3P cookies which are blocked by default. 3P cookies are not blocked by default in Chrome, yet, which is why it may succeed there.

Given that silent calls are not ever guaranteed you should always have a backup plan, e.g. invoke acquireTokenRedirect or acquireTokenPopup in the event the silent call fails.

AzureAD/microsoft-authentication-library-for-js#6765 (comment)
AzureAD/microsoft-authentication-library-for-js#6830

Another reason the token refresh might fail is that the server has configured strict Content Security Policies (CSP) blocking the silent iframe-based login.

Expected Behavior

The AuthenticationService should handle this exception and automatically trigger a new sign in request. It should not result in an unhandled error bubbling up to the Blazor app.

It may have worked this way previously, but this is not the documented behavior. In circumstances where silent token acquisition fails, you are expected to handle the interactive login redirect yourself rather than expect Blazor to automatically redirect for you and possibly unload your application at an unexpected time.

If you are using IAccessTokenProvider.RequestAccessToken() directly, this means you should check if accessTokenResult.Status == AccessTokenResultStatus.RequiresRedirect. If it does, you should call NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl) if want to immediately redirect to login.

If you're getting an AccessTokenNotAvailableException from something like the AuthorizationMessageHandler, you're expected to call AccessTokenNotAvailableException.Redirect()

Can anyone provide a sample where the where the AccessTokenResultStatus is incorrect? Or where either AccessTokenNotAvailableException.Redirect() or NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl) does not work?

I have created a sample repo https://github.com/dino182/blazor-msal-expired-refresh-token to try to recreate the original behaviour I experienced when I opened this issue.

At the time, when the error was encountered, I'm sure the application displayed the unhandled exception Blazor error UI panel, logged the error in the console and stopped processing. If I recall correctly, refreshing the browser resulted in the same behaviour, so the application was effectively unusable from a user's perspective.

However, I cannot reproduce this behaviour now. This repo demonstrates the desired behaviour (in .NET6, .NET7 and .NET8), where the error is logged in the console and something (I don't know what) is causing the sign in request to be automatically retried. Perhaps other framework changes have been introduced since this issue was opened that have fixed the poor behaviour. Whatever has happened, my use-case seems to be working fine and I can't break it now.

I have moved away from MSAL.js and am using same site cookie authentication instead. So, personally, I am happy for this to be closed.

If anyone else still has a problem with this and can respond to @halter73's request please feel free to chip in:

Can anyone provide a sample where the where the AccessTokenResultStatus is incorrect? Or where either AccessTokenNotAvailableException.Redirect() or NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl) does not work?

If not, I'll just leave this issue to be closed automatically.

I have the same problem.

I have this Ihttpclientfactory DelegatingHandler:

   protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
   {
       var accessTokenResult = await _accessor.TokenProvider.RequestAccessToken();
    
       if (accessTokenResult.TryGetToken(out var accessToken) && !String.IsNullOrWhiteSpace(accessToken.Value))
       {
           request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Value);
       }
       else if (accessTokenResult.Status == AccessTokenResultStatus.RequiresRedirect)
       {
           _navigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl);
         // return new HttpResponseMessage(HttpStatusCode.Unauthorized);
       }
....
}

If the user logs in and gets a token... EVERYTHING OK!

accessTokenResult has a valid token.

image

image

But, if I have the user was already logged in for 24h, with a ClaimsPrincipal but expired, then it does not refresh the token.. showing 401 error in the call to an API with JWT

accessTokenResult is null

image

I'm debugging the exception and I find that when I try to get a resfresh-token I get this:

The error :

image

The debug :

image

I have also debugged the POST call in postman :

image

I do not understand your answer @jornjanssen90 .

The real problem in this ticket is that MSAL, once the user is already logged in +24 hours, having a valid authorization and an expired token, cannot request a new token with the access token he had.. giving error 400 in POST on the refresh token request

as you can see here :
image

The real problem in this ticket is that MSAL, once the user is already logged in +24 hours, having a valid authorization and an expired token, cannot request a new token with the access token he had.. giving error 400 in POST on the refresh token request

As I mentioned in #48264 (comment), this is the expected behavior.

Can anyone provide a sample where the where the AccessTokenResultStatus is incorrect? Or where either AccessTokenNotAvailableException.Redirect() or NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl) does not work?

It's been a month since I've asked this, and I haven't seen a sample where this isn't the case. @chulla Has mentioned that there's an issue with their custom DelegatingHandler, but the SendAsync method still attempts the request without an access token which naturally results in a 401 response. The call to NavigationManager.NavigateToLogin does not stop the execution of SendAsync unless it's called during static rendering in which case it throws, and Blazor WebAssembly is not statically rendering.

The built-in AuthorizationMessageHandler throws an exception before attempting any request if it fails to obtain an access token. This exception should be handled by the calling component as you can see demonstrated in my repro project.

Nonetheless, I updated my repro project to implement a custom DelegatingHandler using the SendAsync implementation from #48264 (comment), and other than some noise in the browser network and console tabs from the uncaught exception and the unnecessary request to /WeatherForecast, everything works fine. The app redirects to authentication/login and ultimately login.microsoftonline.com after getting a 400 response from the attempted POST to the /token endpoint in an unsuccessful attempt to use the expired refresh token.

You can take look at the updated repro project here: https://github.com/halter73/BlazorHostedWasmAAD/compare/custom-delegating-handler

browser tools network tab
browser console

If someone can provide a full repro project on GitHub where the AccessTokenResultStatus is incorrect, or where AccessTokenNotAvailableException.Redirect()/NavigationManager.NavigateToLogin(accessTokenResult.InteractiveRequestUrl) does not work, please file a new issue with a link to the repro.

@halter73 OK so for clarity, are you saying that the AuthorizeView Blazor component should render the Authorized section, even if the refresh token is expired? Because it seemed to me that it was failing to do so. Which means I never even get to the point of a DelegatingHandler receiving an exception. But maybe I was mistaken and it is passing through; it's a bit hard to test, needing to wait 24 hours between application startups.

EDIT: It looks like the AuthorizeView component is not a culprit like I thought. Adding a redirect in my handler as below seems to have fixed the issue.

As a sidenote for anyone coming across this in the future who, like me, doesn't want to have to do a try/catch/redirect on every single API request like in the provided sample, I believe something like this should work inside your handler which inherits AuthorizationMessageHandler:

protected override async Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request,
    CancellationToken cancellationToken
)
{
    try
    {
        return await base.SendAsync(request, cancellationToken);
    }
    catch (AccessTokenNotAvailableException tokenException)
    {
        tokenException.Redirect();
        return new HttpResponseMessage { StatusCode = HttpStatusCode.Unauthorized };
    }
}