Blazor integration with Identity Server

x-xss-protection

In this post, I share the code for a Blazor integration with Identity Server and BFF. Nowadays, all browsers don’t allow to share or save an authentication token. So, we have to find alternative ways to authenticate the users. In Duende Identity Server there is a new functionality called BFF. Unfortunately, for commercial use, we have to pay (see the first lines in the image below).

The boilerplate of a Blazor project integrated with the Duende Identity Server is available on my GitHub.

If you want to read more about Identity Server and the integration with Blazor or ASP.NET, see those articles:

What is XSS?

A cross-site scripting (XSS) attack injects malicious code into vulnerable web applications.

The idea behind XSS attacks is to execute malicious JavaScript in the user’s web browser.

In general, XSS attacks are based on the victim’s trust in a legitimate website or vulnerable web application (the general XSS premises).

When they succeed, the executed script can sniff the user’s cookies. If critical data is in the browser storage, it can be accessed.

Stored (Persistent) Cross-Site Scripting attack

This attack stores malicious scripts in the application server. The script can later be executed on the browser. It can affect more than one person. An example is the Twitter XSS attack.

Reflected (Non-Persistent) Cross-Site Scripting attack

The reflected XSS condition is met when a website or web application employs user input in HTML pages returned to the user’s browser, without validating the input first.

With Non-Persistent cross-site scripting, malicious code is executed by the victim’s browser, and the payload is not stored anywhere; instead, it is returned as part of the response HTML that the server sends.

DOM-based XSS

DOM-based XSS vulnerabilities usually arise when JavaScript takes data from an attacker-controllable source, such as the URL, and passes it to a sink that supports dynamic code execution, such as eval() or innerHTML.

Your website is vulnerable to this attack if any user’s inputs in the URL appear in the DOM via methods like innerHTML.

This attack happens on the browser. It does not need a request/response cycle with the server to execute.

Your application server is vulnerable if it doesn’t do the following:

  • If it doesn’t validate user request data.
  • If it doesn’t sanitize data stored on the database.
  • If it doesn’t enable HTTP content-security policy. This prevents scripts from executing in the browser.

Protecting the DOM from XSS injection with React

Modern JavaScript libraries/frameworks like React.js ensure data rendered by the DOM are sanitized by default.

React.js is inspired by XHP. And XHP is a security fix written to minimize XSS attacks as much as possible.

React provides a way to render markup in the DOM from data, even though it is not allowed out of the box. We use the dangerouslySetInnerHTML property on React elements.

Additional Protection Against XSS Attacks

If your application requires you to render markup from data in the DOM, below are some measures to take.

  • To properly secure a user’s cookie, use the HttpOnly response headers.
  • Avoid using browser storage to save sensitive user information.
  • Use HTTP security headers such as Content Security Policy (CSP).

Secure Authentication Flow With Refresh tokens

  • Your authentication has two tokens: the access token and the refresh token.
  • The access token is used to get resources that require authentication from the API. It has a short duration. They are first created with login credentials along with the refresh tokens. As long as the refresh token has not yet expired, credentials are not needed to create access tokens. It is returned in the authentication response body.
  • Use the refresh token to create new access tokens. It has a longer duration. Refresh tokens are created when the user logs in with credentials. They are in the authentication response cookie.
  • Set the refresh token cookie with the httpOnly flag to prevent access via browser JavaScript. Use the secure=true flag so it can only be sent over HTTPS.
  • Save the access token in memory on the front end. Avoid using local storage. Refresh the access token for each page by making a call to the API to refresh.

Why do we need BFF?

Currently, most of the SPA applications are built in a way where tokens (access & refresh) are persisted in the browser. Typically tokens are stored in a session storage which exposes tokens to vulnerabilities and malicious code.

“Currently, SPAs have no means of keeping access and refresh tokens secure from malicious code. Even if developers attempt to protect their apps from XSS attacks (as they should), such an attack can still occur through a vulnerability in a third-party library. The only way to protect tokens from being accessed by any malicious code is to keep them away from the browser”.

Duende recommends BFF implementation for all SPA applications.

What is BFF in shortly?

BFF is an intermediate layer (reverse proxy) between your SPA front end and API services. BFF enables the handling of the tokens and communication to the API services is handled in the backend. The BFF layer is protected with cookie-based authentication. This approach enables that tokens are not required to persist in the browser.

Traditional SPA architecture

SPA architecture with BFF

undefined

Blazor project

Now, we can create a new Blazor application hosted in an ASP.NET Core application. So, the solution has 3 projects: client, server and share. For the purpose of this post, the share project is useless.

Change the server project

In the server project. add the following NuGet packages:

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

Next, we will add OpenID Connect and OAuth support to the backend. For this, we are adding the Microsoft OpenID Connect authentication handler for the protocol interactions with the token service, and the cookie authentication handler for managing the resulting authentication session.

The BFF services provide the logic to invoke the authentication plumbing from the front end (more about this later).

Add the following snippet to your Program.cs above the call to builder.Build();

builder.Services.AddBff();

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = "cookie";
        options.DefaultChallengeScheme = "oidc";
        options.DefaultSignOutScheme = "oidc";
    })
    .AddCookie("cookie", options =>
    {
        options.Cookie.Name = "__Host-blazor";
        options.Cookie.SameSite = SameSiteMode.Strict;
    })
    .AddOpenIdConnect("oidc", options =>
    {
        options.Authority = "https://demo.duendesoftware.com";

        options.ClientId = "interactive.confidential";
        options.ClientSecret = "secret";
        options.ResponseType = "code";
        options.ResponseMode = "query";

        options.Scope.Clear();
        options.Scope.Add("openid");
        options.Scope.Add("profile");
        options.Scope.Add("api");
        options.Scope.Add("offline_access");

        options.MapInboundClaims = false;
        options.GetClaimsFromUserInfoEndpoint = true;
        options.SaveTokens = true;
    });

The last step is to add the required middleware for authentication, authorization and BFF session management. Add the following snippet after the call to UseRouting:

app.UseAuthentication();
app.UseBff();
app.UseAuthorization();

app.MapBffManagementEndpoints();

Finally, you can run the server project. This will start the host, which will in turn deploy the Blazor application to your browser.

Try to manually invoke the BFF login endpoint on /bff/login – this should bring you to the demo IdentityServer. After login (e.g. using bob/bob), the browser will return to the Blazor application.

In other words, the fundamental authentication plumbing is already working. Now we need to make the front end aware of it.

Change the client project

A couple of steps are necessary to add the security and identity plumbing to a Blazor application.

  1. Add the authentication/authorization related Nuget package called Microsoft.AspNetCore.Components.WebAssembly.Authentication and Microsoft.Extensions.Http
  2. Add a using statement to _Imports.razor to bring the above package in scope:
@using Microsoft.AspNetCore.Components.Authorization
  1. To propagate the current authentication state to all pages in your Blazor client, you add a special component called CascadingAuthenticationState to your application. This is done by wrapping the Blazor router with that component in App.razor:
<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
            <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>
  1. Last but not least, we will add some conditional rendering to the layout page to be able to trigger login/logout as well as displaying the current user name when logged in. This is achieved by using the AuthorizeView component in MainLayout.razor:
<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <div class="main">
        <div class="top-row px-4">
            <AuthorizeView>
                <Authorized>
                    <strong>Hello, @context.User.Identity.Name!</strong>
                    <a href="@context.User.FindFirst("bff:logout_url")?.Value">Log out</a>
                </Authorized>
                <NotAuthorized>
                    <a href="bff/login">Log in</a>
                </NotAuthorized>
            </AuthorizeView>
        </div>

        <div class="content px-4">
            @Body
        </div>
    </div>
</div>

When you now run the Blazor application, you will see the following error in your browser console:

crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: Cannot provide a value for property 'AuthenticationStateProvider' on type 'Microsoft.AspNetCore.Components.Authorization.CascadingAuthenticationState'. There is no registered service of type 'Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider'.

CascadingAuthenticationState is an abstraction over an arbitrary authentication system. It internally relies on a service called AuthenticationStateProvider to return the required information about the current authentication state and the information about the currently logged on user.

This component needs to be implemented, and that’s what we’ll do next.

Modifying the frontend

The BFF library has a server-side component that allows querying the current authentication session and state (see here). We will now add a Blazor AuthenticationStateProvider that will internally use this endpoint.

Add a file with the following content:

using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;

namespace Blazor6.Client.BFF;

public class BffAuthenticationStateProvider 
    : AuthenticationStateProvider
{
    private static readonly TimeSpan UserCacheRefreshInterval 
        = TimeSpan.FromSeconds(60);

    private readonly HttpClient _client;
    private readonly ILogger<BffAuthenticationStateProvider> _logger;

    private DateTimeOffset _userLastCheck 
        = DateTimeOffset.FromUnixTimeSeconds(0);
    private ClaimsPrincipal _cachedUser 
        = new ClaimsPrincipal(new ClaimsIdentity());

    public BffAuthenticationStateProvider(
        HttpClient client,
        ILogger<BffAuthenticationStateProvider> logger)
    {
        _client = client;
        _logger = logger;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        return new AuthenticationState(await GetUser());
    }

    private async ValueTask<ClaimsPrincipal> GetUser(bool useCache = true)
    {
        var now = DateTimeOffset.Now;
        if (useCache && now < _userLastCheck + UserCacheRefreshInterval)
        {
            _logger.LogDebug("Taking user from cache");
            return _cachedUser;
        }

        _logger.LogDebug("Fetching user");
        _cachedUser = await FetchUser();
        _userLastCheck = now;

        return _cachedUser;
    }

    record ClaimRecord(string Type, object Value);

    private async Task<ClaimsPrincipal> FetchUser()
    {
        try
        {
            _logger.LogInformation("Fetching user information.");
            var response = await _client.GetAsync("bff/user?slide=false");

            if (response.StatusCode == HttpStatusCode.OK)
            {
                var claims = await response.Content.ReadFromJsonAsync<List<ClaimRecord>>();

                var identity = new ClaimsIdentity(
                    nameof(BffAuthenticationStateProvider),
                    "name",
                    "role");

                foreach (var claim in claims)
                {
                    identity.AddClaim(new Claim(claim.Type, claim.Value.ToString()));
                }

                return new ClaimsPrincipal(identity);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Fetching user failed.");
        }

        return new ClaimsPrincipal(new ClaimsIdentity());
    }
}

and register it in the client’s Program.cs:

builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<AuthenticationStateProvider, BffAuthenticationStateProvider>();

If you run the server app now again, you will see a different error:

fail: Duende.Bff.Endpoints.BffMiddleware[1]
      Anti-forgery validation failed. local path: '/bff/user'

This is due to the antiforgery protection that is applied automatically to the management endpoints in the BFF host. To properly secure the call, you need to add a static X-CSRF header to the call. See here for more background information.

This can be easily accomplished by a delegating handler that can be plugged into the default HTTP client used by the Blazor frontend. Let’s first add the handler:

public class AntiforgeryHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        request.Headers.Add("X-CSRF", "1");
        return base.SendAsync(request, cancellationToken);
    }
}

and register it in the client’s Program.cs (overriding the standard HTTP client configuration; requires package Microsoft.Extensions.Http):

// HTTP client configuration
builder.Services.AddTransient<AntiforgeryHandler>();

builder.Services.AddHttpClient("backend", client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
    .AddHttpMessageHandler<AntiforgeryHandler>();
builder.Services.AddTransient(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("backend"));

If you restart the application again, the logon/logoff logic should work now. In addition you can display the contents of the session on the main page by adding this code to Index.razor:

@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, Blazor BFF!</h1>

<AuthorizeView>
    <Authorized>
        <dl>
            @foreach (var claim in @context.User.Claims)
            {
                <dt>@claim.Type</dt>
                <dd>@claim.Value</dd>
            }
        </dl>
    </Authorized>
</AuthorizeView>

Securing the local API

The standard Blazor template contains an API endpoint (WeatherForecastController.cs). Try invoking the weather page from the UI. It works both in logged in and anonymous state. We want to change the code to make sure, that only authenticated users can call the API.

The standard way in ASP.NET Core would be to add an authorization requirement to the endpoint, either on the controller/action or via the endpoint routing, e.g.:

app.MapControllers()
        .RequireAuthorization();

When you now try to invoke the API anonymously, you will see the following error in the browser console:

Access to fetch at 'https://demo.duendesoftware.com/connect/authorize?client_id=...[shortened]... (redirected from 'https://localhost:5002/WeatherForecast') from origin 'https://localhost:5002' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

This happens because the ASP.NET Core authentication plumbing is triggering a redirect to the OpenID Connect provider for authentication. What we really want in that case is an API friendly status code – 401 in this scenario.

This is one of the features of the BFF middleware, but you need to mark the endpoint as a BFF API endpoint for that to take effect:

app.MapControllers()
        .RequireAuthorization()
        .AsBffApiEndpoint();

After making this change, you should see a much better error message:

Response status code does not indicate success: 401 (Unauthorized).

The client code can properly respond to this, e.g. triggering a login redirect.

When you logon now and call the API, you can put a breakpoint server-side and inspect that the API controller has access to the claims of the authenticated user via the .User property.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.