Part of a series on Blazor WASM initial load performance

  1. Server Prerendering
  2. Improving Data Loading
  3. Authentication In Prerendering

Part 3: Authentication In Prerendering

The changes already shown in the previous posts have made for a fast initial page load with data showing when available but so far everything being done has been anonymous pages. What if that initial page load also needs to be authenticated? According to the docs for Prerendering and docs for WASM security it is isn’t supported for authentication endpoints (/authentication/ path segment). For many applications always starting off with an un-authenticated page may be unacceptable.

By using a BFF (Back-end For Front-end) authentication pattern with OIDC for the client application it is possible to enforce authentication during Prerendering by employing a few adjustments on top of the usual authentication changes. If done properly this process can work equally well with both logged in users, who will get a fast page load, and for anonymous users that will require login redirects before that initial page.

For the authentication itself I’m using the server side BFF to handle login redirects and tokens, with the WASM client only receiving a cookie that will contain the current token. The Duende BFF library can handle most of that work for me after completing the setup steps here. The server will also need a registration for an AuthenticationStateProvider implementation added to Program.cs, in this case one already provided by the framework.

builder.Services.TryAddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();

I’m also going to adjust the display of the user’s name in MainLayout so that it will work the same way on both server and client (client side in this case benefits from some claim remapping that the server doesn’t do by default).

<Authorized>
    <strong>Hello, @context.User.FindFirst("name")?.Value!</strong>
    <a href="@context.User.FindFirst("bff:logout_url")?.Value">Log out</a>
</Authorized>

Completing these steps adds a login button that the user can choose to use but for my data loading page I want to force users to authenticate and not allow the APIs to be called anonymously.

Login button

I’ve taken care of this on the server side by including app.MapControllers().RequireAuthorization() in my startup code to secure the APIs. Adding Authorize attributes to page components does the same for the client side.

[Authorize]
public partial class FetchData : IDisposable
{
}

If I try navigating to my fetchdata page when not logged in now I do get a failure to load the page, but it’s not yet the login redirect that I really want.

Authorization Failure

Looking at the URL, it’s trying to load the /bff/login page which should allow the user to log in, but that route doesn’t exist in my client, only on the server side. To get the redirect to work a need to add a little more code. First, a new RedirectToLogin.razor component in the client’s Shared folder.

@inject NavigationManager Navigation

@code {
    protected override void OnInitialized()
    {
        var returnUrl = Uri.EscapeDataString("/" + Navigation.ToBaseRelativePath(Navigation.Uri));
        Navigation.NavigateTo($"bff/login?returnUrl={returnUrl}", forceLoad: true);
    }
}

And then adding the new component into the top level layout in App.razor:

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (context.User.Identity?.IsAuthenticated != true)
                    {
                        <RedirectToLogin />
                    }
                    else
                    {
                        <p role="alert">You are not authorized to access this resource.</p>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="@routeData" Selector="h1"/>
        </Found>
        <NotFound>
            <PageTitle>Not found</PageTitle>
            <LayoutView Layout="@typeof(MainLayout)">
                <p role="alert">Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Now if a user that hasn’t logged in starts at the root (anonymous) page and then navigates to my secured fetchdata page, they will be appropriately redirected to login and then back to the page they tried to get to. If a user has already logged in (has a valid cookie for the site) they will show as logged in right away when navigating to any page. For the fetchdata page, that user will even get all the benefits of the Prerendering that was set up before.

The situation is now pretty good for a logged in user or someone starting at an anonymous page, but the goal here is to allow the authenticated page to load (fast) as the first page, and behave well whether a user is logged in yet or not. So what happens for that type of user now? It’s good news at first, as the initial page load results in appropriate login redirects to end up at the login page.

Login Redirects

After logging in the news is not so good though. The browser gets stuck in a redirect loop as it tries to return the newly authenticated session back to the client. This is where the lack of authentication support in Prerendering shows up. The IDP that performed the login is trying to return to the client that originated the request to complete authentication, but it hasn’t been initialized yet because of the redirect away. Before the client can load, the server is trying to prerender the target page but isn’t able to because it requires the not-yet-complete authentication, so redirects back to the IDP to log in.

The beginning of the solution for this can be found in the docs for additional authentication scenarios. In _Host.cshtml instead of using WebAssemblyPrerendered on every page, a conditional is needed to see if the page being loaded will support Prerendering or not.

<div id="app">
    @if (HttpContext.Request.Path.StartsWithSegments("/authentication"))
    {
        <component type="typeof(App)" render-mode="WebAssembly" />
    }
    else
    {
        <component type="typeof(App)" render-mode="WebAssemblyPrerendered" />
    }

    <persist-component-state />
</div>

This looks like a good start, but the /authentication path used in the docs doesn’t quite make sense here. This is assuming that the app is made up of separate authenticated and unauthenticated pages, indicated by a specific part of the page path. Furthermore, if we were to use this method, it would mean that all of the work up to this point to make pages load fast just won’t be applied at all to the fetchdata page, even if a user had already authenticated.

What’s really needed here is a condition that’s aware of the current state of the app and can disable Prerendering on any page, but only when it is the target of a login redirect. This could be tricky, but we do happen to already have code that is being executed at the beginning of the login redirect process in the RedirectToLogin component. By adding something extra to the returnUrl this can provide the signal needed only at appropriate times.

protected async override Task OnInitializedAsync()
{
    var returnUrl = Uri.EscapeDataString("/" + Navigation.ToBaseRelativePath(Navigation.GetUriWithQueryParameter("authredirect", true)));
    Navigation.NavigateTo($"bff/login?returnUrl={returnUrl}", forceLoad: true);
}

Now changing the if to check for the authredirect query string parameter finally provides the desired behavior.

<div id="app">
    @if (HttpContext.Request.Query.TryGetValue("authredirect", out var isRedirect)
            && isRedirect.Any(r => r?.Equals(bool.TrueString, StringComparison.OrdinalIgnoreCase) == true))
    {
        <component type="typeof(App)" render-mode="WebAssembly" />
    }
    else
    {
        <component type="typeof(App)" render-mode="WebAssemblyPrerendered" />
    }

    <persist-component-state />
</div>

The first page after login won’t get the benefits of Prerendering, but going directly to any page with a logged in browser session will, whether or not it requires authentication.

With the potential for upcoming “Blazor United” changes in .NET 8, the lines between server and client will blur even more, and hopefully authentication will become better integrated from the start, but for now at least .NET 7 allows for it with a little extra work.

Code Versions

Example code is using .NET 7.0 with these library versions:

  • Microsoft.AspNetCore.Authentication.OpenIdConnect 7.0.2
  • Duende.BFF 2.0.0