dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.51k stars 10.04k forks source link

MSAL on Blazor WebAssembly fails to initiate sign-in when an invalid_grant or AADSTS700081 error occurs--as in when the refresh token is expired #28151

Closed szalapski closed 3 years ago

szalapski commented 3 years ago

Describe the bug

MSAL on Blazor WebAssembly fails to initiate sign-in when an invalid_grant or AADSTS700081 error occurs--as in when the refresh token is expired

To Reproduce

My MSAL on the client is configured as:

            builder.Services.AddMsalAuthentication(options =>
            {
                builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
                options.ProviderOptions.Cache.CacheLocation = "localStorage";
                options.ProviderOptions.DefaultAccessTokenScopes.Add(
                    builder.Configuration["AzureAd:MyScopeId"]);
                options.UserOptions.RoleClaim = "roles";            
            });

I sign in to my Blazor Web Assembly app, then wait till my refresh token expires (for me, 1 day). Then I try to refresh the page, which includes a component like this:

      <AuthorizeView>
        <Authorized>
          <span>@context.User.UserId()</span>
        </Authorized>
        <Authorizing>
            Authorizing
        </Authorizing>
      </AuthorizeView>

Expected behavior

The page should show "Authorizing", then the code in MSAL that AuthorizeView triggers should automatically initiate a redirect to sign-in, so that the user can go through authentication and thus get a new refresh token and ID token. (Once signed in, the user should redirect back to the same page, which should show the content within the <Authorized> fragment.)

Actual behavior

The page shows "Authorizing", and the HTTP request POST https://login.microsoftonline.com/0c33cce8-883c-4ba5-b615-34a6e2b8ff38/oauth2/v2.0/token returns HTTP 400 with

    error "invalid_grant"
    error_description "AADSTS700081:  The refresh token has expired due to maximum lifetime. The token was  issued on 2020-11-24T12:56:15.5198672+00:00 and the maximum allowed  lifetime for this application is 1.00:00:00.\r\nTrace ID:  c4360626-5489-4009-89ad-5ae02bd0ca00\r\nCorrelation ID:  228a7671-3752-4ca9-bf1f-7c0c51368fb6\r\nTimestamp:     

Then Blazor allows an exception to be thrown with Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100] Unhandled exception rendering component: login_required: AADSTS50058: A silent sign-in request was sent but no user is signed in. and further detail. The error is written to the browser console and Blazor shows the standard "An unhandled error has occurred. Reload" bottom banner.`

Possible Solution

Isn't there some way to configure MSAL to initiate the interactive sign-in process on invalid_grant, rather than having it fail fatally? Or is this just a big bug? Any such action would have to navigate/redirect or popup on the user's browsing page, not naively redirect an XHR request, of course.

This seems to be similar to: https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/2219 , though I am not using MSAL.js directly.

This looks like my situation exactly, but I don't see how I can mitigate. My code never explicitly calls AcquireTokenSilent.

Additional context/ Logs / Screenshots

Here's the end of the stack trace: https://gist.github.com/szalapski/942baf9b8da7b5bdb68ebd7f9e2f5544

(Thought I'd post first on MSAL repo, but they say it is a aspnetcore issue.)

javiercn commented 3 years ago

@szalapski thanks for contacting us.

This indeed looks like an issue, I'm not 100% positive I understand the scenario, from what I get it happens when the refresh token has expired.

The question that I have is about the refresh, does it error out while trying to authenticate the user to visit a page, or does it error out when the app is trying to provision a token to talk to an API?

From what I can see in the call stack I think is the first scenario, but I want to confirm.

ghost commented 3 years ago

Thanks for contacting us. We're moving this issue to the Next sprint planning milestone for future evaluation / consideration. We will evaluate the request when we are planning the work for the next milestone. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

szalapski commented 3 years ago

"The question that I have is about the refresh, does it error out while trying to authenticate the user to visit a page, or does it error out when the app is trying to provision a token to talk to an API?"

Great question. I believe we are doing both, but of course we can't really see explicit code that does each part. However, it seems right that this is the first scenario--we are trying to render the results of the AuthorizeView markup in the main page's .razor component, not doing any API call yet.

The 400 invalid_grant with AADSTS700081 seems to be in attempting to refresh the expired ID token (which seems to live for 1 hour 5 minutes), but it cannot because the refresh token is also expired (lifetime seems to be 1 day, set on the AD side). When this happened, I have not yet attempted any API call that requires authorization/authentication.

(By the way, we also get similar symptoms when attempting an authorization-required XHR request, but there we can catch a AccessTokenNotAvailableException and redirect to login as a mitigation, as in this example. When just trying to get the user's info on the client only, we cannot catch an exception, as it is all black-box magic inside <AuthorizeView>. I suspect fixing the core problem here would let us avoid that exception that occurs later, as the client UI would first take care of signing in the user and their tokens would all be valid before attempting a authorization-required XHR request.)

szalapski commented 3 years ago

Could this be considered a bug? I doubt it was designed so that an expired refresh token would result in an exception thrown, and an uncatchable one at that. Not sure, just curious.

szalapski commented 3 years ago

If this helps, here are all the page and XHR requests this makes, from the Network tab in Firefox developer tools. Hopefully this helps you narrow it down, with the "invalid_grant" request and response selected.

image

szalapski commented 3 years ago

Hmm, I realize that I can't even see a workaround--how do I prompt the user to re-login? If I try to redirect them to sign-in, I just get the same error again. I have to make them sign out, perhaps? If so, this makes things a little more urgent to fix.

szalapski commented 3 years ago

Accidentally clicked close.

mkArtakMSFT commented 3 years ago

@captainsafia can you please handle this? Let's target 5.0.2.

captainsafia commented 3 years ago

@mkArtakMSFT Sure. I'll take a look at this. Behavior is a little surprising because we have some logic that falls back to authenticate via a pop-up if silent sign-in fails but there might be something funky afoot.

captainsafia commented 3 years ago

Update: I see where the issue is. We don't have any error handling in the token acquisition in our getUser method which causes exceptions that bubble up from it to be fatal.

We didn't catch this error in our validations/testing because we don't run into the experienced tokens often based on our test scripts and dev loops.

The fix here is to add some error handling to the getUser method.

szalapski commented 3 years ago

Great.

Am I right to assume that the fix would apply to <AuthorizeView> particularly, or is it broader than that?

Particularly, will the behavior change for XHR requests from an httpClient in a Blazor component? Obviously an XHR request ought never redirect to login. Currently we have to catch an AccessTokenNotAvailableException e in our component and then call e.Redirect() in the catch block so that the login sequence happens in the user's browser, not in the XHR request, as in this example. My guess is that the fix you are working on here won't strictly affect that behavior? (This will be hard for us to test so I hope I have explained it adequately; if not, please let's dialog to be sure it is clear to both of us.)

(Of course, if we have good use of <AuthorizeView> it would prevent most refresh-token-expired XHR requests from being attempted, but that is beside the point here.)

captainsafia commented 3 years ago

@szalapski It applies to any scenario where the GetUser API so it shouldn't affect the error handling you're using around HTTP requests.

szalapski commented 3 years ago

I have learned that this symptom happens not only when I use <AuthorizeView> as above, but also when I use <RemoteAuthenticatorView>, e.g. to redirect to logout. So I cannot even provide a workaround to log out and then back in because beginning the logout process fails before it even starts. I get the same AADSTS700081: The refresh token has expired due to maximum lifetime error, but I then subsequently get Unhandled exception rendering component: state_mismatch: State mismatch error. Please check your network. Continued requests may cause cache overflow. as well. This strikes me as a related but separate error, perhaps?

szalapski commented 3 years ago

We'd like to go live on our app soon, but this is a potential blocker. I really appreciate that you are already working on it. Can you suggest any kind of workaround? So far the only way I can see to recover is to have the user clear their local storage.

I can prompt the user via a timed HTML fragment that only appears if the user stays on the "Authentication" page for more than 5 seconds (which isn't likely to happen except for this error). I would like the user to click on a link or button to recover from this state, but I cannot see what link destination could possibly help, given that <RemoteAuthenticatorView> is broken too. Is there any way on the client to totally invalidate their sign-in state?

captainsafia commented 3 years ago

We'd like to go live on our app soon, but this is a potential blocker. I really appreciate that you are already working on it. Can you suggest any kind of workaround? So far the only way I can see to recover is to have the user clear their local storage.

You can try programmatically calling window.AuthenticationService.signIn via JS interop from your application code when the user clicks on the button.

szalapski commented 3 years ago

Great, I got the workaround working. In case anyone else wants to do the same, here is the code:

Part of MainLayout.razor:

@inject IJSRuntime JS
@* ... *@
     <AuthorizeView>
        <NotAuthorized>
           @* ... *@
        </NotAuthorized>
        <Authorized>
           @* ... *@
        </Authorized>
        <Authorizing>
          @* Mitigation for https://github.com/dotnet/aspnetcore/issues/28151 - can remove when fixed*@
          <div class="m-4 authentication-unhandled-failure-message">
            If this gets stuck, just
            <a class="cursor-pointer" @onclick="RefreshTokenExpiredFailureWorkaround">sign in again</a>.
          </div>
        </Authorizing>
      </AuthorizeView>

@code
{
  // see also https://github.com/dotnet/aspnetcore/issues/28151
  private async Task RefreshTokenExpiredFailureWorkaround() =>
    await JS.InvokeVoidAsync("window.AuthenticationService.signIn");
}

CSS:

/* Mitigation for https://github.com/dotnet/aspnetcore/issues/28151 - can remove when fixed*/
.authentication-unhandled-failure-message {
    // Since we cannot detect some errors in authenticating, using animation to show after a delay
    animation: fade-in 5s step-end;
    animation-fill-mode: both;
    opacity: 0;
}
.cursor-pointer {
    cursor: pointer;
}
patrick-robin commented 3 years ago

Is there anyway of capturing the InteractionRequiredAuthError so that we can either automate the redirect to window.AuthenticationService.signIn or prevent the default blazor-error-ui from being displayed?

captainsafia commented 3 years ago

@szalapski Glad the workaround resolved the issue.

A fix for this will be shipped in 5.0.2.