authenticationblazorblazor-webassemblyazure-ad-msal

How can I prevent or globally react to uncaught AccessTokenNotAvailableExceptions using Blazor WebAssembly with MSAL authentication?


When a access token is unavailable, can you think of any elegant way to do the redirect to an interactive login whenever AccessTokenNotAvailableException is thrown, in one place? Or, even better, is there a way to know when the token is invalid or unavailable even though AuthenticationState.User.Identity?.IsAuthenticated == true, so that I can avoid the attempt in those cases?

Details:

Using MSAL for authentication in Blazor WebAssembly: when I use any of the various HttpClient.Get*Async functions, I configure a named client that will always attach a Bearer token to outgoing API requests.

In client Program.cs:

 builder.Services.AddHttpClient("Authenticated", client =>
         client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
     .AddHttpMessageHandler<CustomAuthorizationMessageHandler>();

In CustomAuthorizationMessageHandler.cs:

/// <summary>
/// Customer handler for Authorization that is necessary when submitting REST API calls to end points that 
/// are different then you base address for your application. 
/// Information on this topic can be found at 
/// https://docs.microsoft.com/en-us/aspnet/core/blazor/security/webassembly/additional-scenarios </summary>
public class CustomAuthorizationMessageHandler : AuthorizationMessageHandler
{
    public CustomAuthorizationMessageHandler(IAccessTokenProvider provider,
        NavigationManager navigationManager, IConfiguration Configuration)
        : base(provider, navigationManager)
    {
        ConfigureHandler(
            authorizedUrls: new[] { $"{Configuration.GetRequired("ServiceUrl")}" }
            //,
            //scopes: new[] { "User.Read", "User.Write" }
        );
    }
}

So in my code,when AuthenticationState.User.Identity?.IsAuthenticated is false or null, I can avoid the HTTP call attempt. But when AuthenticationState.User.Identity?.IsAuthenticated == true, a user could sometimes get AccessTokenNotAvailableExceptions thrown in this process, such as when but the user's sign-in session is invalid or the tokens have been manually removed in the browser.

So most of the time I await such requests, and I have written a helper extension method that I can chain to the awaited call that will catch any exception on await and then redirect to the interactive sign-in pages as appropriate so that the user has a good experience signing in again as needed, rather than seeing a "broken page" from uncaught AccessTokenNotAvailableExceptions.

public static async Task<T?> CheckForTokenUnavailable<T>(this Task<T?> task)
     where T : class
 {
     try
     {
         return await task;
     }
     catch (AccessTokenNotAvailableException e)
     {
         e.Redirect(); // starts interactive sign-in
         return null;
     }
 }

Now I only want to call this when HTTP requests are being awaited in any GUI component. If I were to call it from an XHR request that is initiated in some other way, such as from MSAL event handlers or somewhere like that, the resulting redirect to an interactive login would get swallowed up in the XHR context and result in CORS errors.

So anyway, the problem is that I have to invoke this everywhere I make a HttpClient request from a component. In hundreds of places, I manually chain the CheckForTokenUnavailable method to whatever client call I need to make (which varies), such as:

if (AuthenticationState.User.Identity?.IsAuthenticated == true){
  string url = $"{ServiceUrl}/items/{itemId}/childIds";
  var client = ClientFactory.CreateClient("Authenticated");
  return await client.GetFromJsonAsync<List<int>>(url).CheckForTokenUnavailable();
}

I spend a bunch of time today trying to do so using an ErrorBoundary. I inherited from ErrorBoundary and overrode OnErrorAsync to look for a AccessTokenNotAvailableException, but there must be some nuance in the ErrorBoundary that I don't understand, because it seems to do the e.Redirect() that I want but then never redirects back to the caller--instead I end up stuck either on the source page with more AccessTokenNotAvailableExceptions thrown, or seeing the ErrorContent page doing nothing. It needs to allow the MSAL client to redirect back to the caller and try again, but I cannot make it do so. Though I don't fully understand why this is, I now suspect that ErrorBoundary is the wrong mechanism to try to achieve calling e.Redirect whenever necessary. So I am not including my ErrorBoundary code as I think that would be barking up the wrong tree, and I'll ask my higher-level questions instead, bolded above--let me know if you think ErrorBoundary is my best bet and I can revisit that, though I doubt it.


Solution

  • To avoid getting AccessTokenNotAvailableException, I now first check for the token before all attempts to use it. For my Blazor WebAssembly site, that means putting a check in my main layout component, and only rendering @body once it returns true; if false, redirect to interactive login. If I do this, I won't get AccessTokenNotAvailableException on the first API attempt.

    @inject IOptionsSnapshot<RemoteAuthenticationOptions<ApiAuthorizationProviderOptions>> OptionsSnapshot
    /* ... */
    @code {
    
    private bool ShowBody { get; set; } = false;
    protected override async Task OnInitializedAsync()
    {
        if ((await AuthenticationStateTask).User?.Identity?.IsAuthenticated == true)
        {
            // The check for access token needs to be done in this file 'MainLayout.razor' because, we need to be successfully
            // authenticated on the page before we can test to see if our access token for our main data API
            // is still valid. We want to make this call before anything else tries to access the service for a call that requires
            // user credentials.
            ShowBody = await HasValidAccessToken();
            StateHasChanged();
        }
    }
    
    public async Task<bool> HasValidAccessToken()
    {
        if (NavigationManager.Uri.Contains("login-callback"))
        {
            // We get here after the login redirect returns to our site.
            // MSAL saving the token is still in progress. Not sure why. Workaround is to delay.
            await Task.Delay(1000);  // 1000ms seems fine; 500 ms wasn't enough.
        }
        AccessTokenResult tokenResult = await TokenProvider.RequestAccessToken();
        if (tokenResult.TryGetToken(out AccessToken? token))
        {
            return true;
        }
        else
        {
            NavigationManager.NavigateToParagonSignIn(OptionsSnapshot);
            return false;
        }
    }
    
    }
    

    For me, my main layout is the layout I use for all components that need authentication for them to show anything at all. Other layouts are for components that don't need authentication (especially the authentication page itself--the one that has the <RemoteAuthenticatorView Action="@Action" /> component). If you do your layouts differently, you will have to think critically about where to put such a check.

    This only solves the problem when it might occur at app load time, which was most of my problem.