Search code examples
authenticationblazorblazor-server-sideblazor-webassemblyblazor-rendermode

Blazor MSAL Authentication Lost After Sitting Idle


I have a .Net 8.0 Blazor application that has both a Server project and WASM project.

In our Test Environment, the Blazor app sits in a private vNet with all access directed through Front Door. This is important to mention because websockets do not work through Front Door at this time, and so the Blazor application falls back to longpolling.

Authentication is done through Entra (customer tenant) with MSAL. We use a Downstream API to generate our own custom claims and inject them into the user context.

MSAL registration

var initialScopes = configuration.GetSection("AzureAd:Scopes").Get<string[]>();

services.AddMicrosoftIdentityWebAppAuthentication(configuration, "AzureAd", subscribeToOpenIdConnectMiddlewareDiagnosticsEvents: detailedErrorsEnabled)
    .EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
    .AddDownstreamEntraApi(ConfigurationConstants.ClaimsProviderName, configuration.GetDownstreamApiConfig(ConfigurationConstants.ClaimsProviderName))
    .WithHttpClient(ConfigurationConstants.ClaimsProviderName) // this is a custom extension for registering downstream (not really important to the issue at hand)
    .AddInMemoryTokenCaches();

What is happening is when you load the Blazor application, you are authed correctly and sent to your dashboard appropriately by policy. However, allowing the application to sit idle for about 30 seconds causes it to lose connection with the server and then you get the dreaded Attempting to Reconnect to Servier: 1 of 8. This spins for about a second and then immediately forwards the user on reconnect to our 403 page. Clicking to navigate back via button in our UI allows you back into the protected area.

I have a policy where when you do not have our extra claims, you are sent to a page that can use MSAL and downstreamapi authorization to perform a consent handshake if necessary, get the claims from the API, and then inject them into the authentication state provider where they are cached.

We are storing these in memory cache by the user's tenant and object id out of Entra so that subsequent calls that need the claims do not have to go to the api (eventually this will be replaced with External Id features in development I'm not sure I can discuss)

public class InjectedClaimsAuthenticationStateProvider(ILoggerFactory loggerFactory,
    IEntraUserContextFactory contextFactory,
    ICacheManager cache,
    ICacheKeyFactory keyFactory) : ServerAuthenticationStateProvider, IAuthenticationStateProvider
{
    private AuthenticationState? _authState = null;

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        var authState = await base.GetAuthenticationStateAsync();

        if (!authState.User.IsAuthorizedEntraTenant())
        {
            var claims = await GetClaimsAsync(authState);

            if (claims.Any())
            {
                authState.User.AddIdentity(new ClaimsIdentity(claims, "claims-provider-injected"));
                base.SetAuthenticationState(Task.FromResult(authState));
            }
        }

        return authState;
    }

    private string GetCacheKey(IEntraUserContext context)
    {
        return keyFactory.CreateCacheKey("injected-claims", context.EntraTenantId, context.EntraUserIdentifier);
    }

    public async Task ClearInjectedClaimsAsync(IEntraUserContext context)
    {
        var key = GetCacheKey(context);

        if (cache.KeyExists(key))
        {
            await cache.ExpireAsync(key);
        }
    }

    public async Task InjectClaimsAsync(IEntraUserContext context, ClaimsProviderResponseDto response)
    {
        var key = GetCacheKey(context);

        if (cache.KeyExists(key))
        {
            await cache.ExpireAsync(key);
        }

        await cache.AddAsync(key, response.Claims.SelectMany(x =>
        {
            var values = $"{x.Value}".Split(',', StringSplitOptions.TrimEntries);

            return values.Select(v => new Claim(x.Key, v));
        }).ToArray(), TimeSpan.FromHours(1)); //todo: make this configurable
    }

    public async Task<Claim[]> GetClaimsAsync(AuthenticationState authenticationState)
    {
        var logger = loggerFactory.CreateLogger<InjectedClaimsAuthenticationStateProvider>();

        try
        {
            var entraClaims = authenticationState.User.Claims.ToArray();

            var context = contextFactory.GetUserContext(entraClaims);

            var key = GetCacheKey(context);

            if (cache.KeyExists(key))
            {
                return await cache.GetAsync<Claim[]>(key);
            }

            return Array.Empty<Claim>();
        }
        catch (MsalUiRequiredException ex)
        {
            logger.WriteException(ex, "Exception thrown while getting claims");
        }
        catch (Exception e)
        {
            //eat any errors
            logger.WriteException(e, "Exception thrown while getting claims");
        }

        return Array.Empty<Claim>();
    }
}

I'm not sure why Auth state is being lost when the application sits idle. I had thought that maybe the client side needed a copy of the claims, so I tried using the Server and Client auth providers from this repo, but it behaves the exact same way.

Just to give a bit more clarity, here is how I have my policies set up using an Auth View hierarchy. We have two user types, Advisor being the parent or owner of multiple clients.

This component resides on the root page and works to navigate you to the correct landing page or send you out to Entra to authenticate. This is in the WASM project.

@inject NavigationManager NavigationManager

<CascadingAuthenticationState>
    <AuthorizeView Policy="@PolicyConstants.IsAdmin" Context="AdminContext">
        <Authorized>
            <RedirectToAdminDashboardComponent/>
        </Authorized>
        <NotAuthorized>
            <AuthorizeView Policy="@PolicyConstants.IsAdvisor" Context="AdvisorContext">
                <Authorized>
                    <RedirectToAdvisorDashboardComponent/>
                </Authorized>
                <NotAuthorized>
                    <AuthorizeView Policy="@PolicyConstants.IsClient" Context="ClientContext">
                        <Authorized>
                            <RedirectToClientDashboardComponent/>
                        </Authorized>
                        <NotAuthorized>
                            @{
                                var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
                                <RedirectToClaimsProvider ReturnUrl="@returnUrl" />
                            }
                        </NotAuthorized>
                    </AuthorizeView>
                </NotAuthorized>
            </AuthorizeView>
        </NotAuthorized>
    </AuthorizeView>
</CascadingAuthenticationState>

For each Policy, we also have a component for protecting pages.

Admin Policy Component in the WASM project

<AuthorizeView Policy="@PolicyConstants.IsAdmin">
    <Authorized>
        @ChildContent
    </Authorized>
    <Authorizing>
        <LoadingPopoverComponent Text="Authorizing" IsVisible="true" />
    </Authorizing>
    <NotAuthorized>
        @{
            <RedirectTo403Component />
        }
    </NotAuthorized>
</AuthorizeView>

And this is used in a layout in the WASM project

@inherits BaseLayoutComponent
@layout BasePageLayout

<AdminPolicyComponent>
    @Body
</AdminPolicyComponent>

Which is then used in the page in the WASM project

@page "/admin"
@inherits BasePage
@layout AdminPolicyContentPageLayout
@rendermode InteractiveAuto

According to this post, it seems what might be happening is that the tab "goes to sleep" and for some reason loses all context of authentication. I haven't found any workarounds for this that work (the code above did not correct the issue to pass the claims down via PersistentComponentState)


Solution

  • After a lot of trial and error, the problem ended up being that in our test environment, because websockets do not work through Azure Front Door, long-polling does not appear to keep sessions alive.

    So, every 30 seconds or so, the app disconnecting and reconnecting is getting a new session, and yet somehow auth context was being lost - even though I was A) saving claims in a static memory cache, B) passing those saved claims to the client via PersistentComponentState and C) saving them on the client side into the browser's session storage.

    The answer became quite simple:

    1. Stand up Azure Signal R and configure the app to use Azure Signal R

    or

    1. Don't use Azure Front Door, don't put the blazor app in a vNet, and leave it open to the world. (unacceptable for our security)

    So we went with option 1. Here's the config for anyone insterested.

    if (environment.IsDevelopment())
    {
        services.AddSignalR();
    }
    else
    {
        var signalRConnectionString = configuration[ConfigurationConstants.ConfigurationKeys.SignalRConnectionString];
    
        if (!string.IsNullOrWhiteSpace(signalRConnectionString))
        {
            services.AddSignalR().AddAzureSignalR(signalRConnectionString);
        }
    }