Implement a secure Blazor Web application using OpenID Connect and security headers

This article shows how to implement a secure .NET 8 Blazor Web application using OpenID Connect and security headers with CSP nonces. The NetEscapades.AspNetCore.SecurityHeaders nuget package is used to implement the security headers and OpenIddict is used to implement the OIDC server.

Code: https://github.com/damienbod/BlazorWebOidc

OpenIddict is used as the identity provider and an OpenID connect client is setup to allow an OpenID Connect confidential code flow PKCE client. The Web application is a server rendered application using Blazor server components implemented using Blazor Web, ASP.NET Core and .NET 8.

Step 1: Init solution from the .NET Blazor samples

The solution was created using the Blazor samples from Microsoft. The .NET 8 BlazorWebAppOidc project was used to setup the solution.

https://github.com/dotnet/blazor-samples/tree/main/8.0/BlazorWebAppOidc

The code sample implements the client profile parts and the CSRF protection. Login and Logout plumbing is also implemented.

Step 2: Switch the OpenID Connect server

OpenIddict is used as the identity provider and so the OIDC client set up needs to be changed. The program file was updated and the OpenID Connect Microsoft Entra ID client was replaced with the OpenIddict client. The client on the server is setup directly in the worker class in the Openiddict server. Both of the setups must match. The client uses an OpenID Connect confidential client with code flow and PKCE.

builder.Services.AddAuthentication(OIDC_SCHEME)
    .AddOpenIdConnect(OIDC_SCHEME, options =>
    {
        // From appsettings.json, keyvault, user-secrets
        // "OpenIDConnectSettings": {
        //  "Authority": "https://localhost:44318",
        //  "ClientId": "oidc-pkce-confidential",
        //  "ClientSecret": "--secret-in-key-vault-user-secrets--"
        // },
        builder.Configuration.GetSection("OpenIDConnectSettings").Bind(options);

        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.ResponseType = OpenIdConnectResponseType.Code;

        options.SaveTokens = true;
        options.GetClaimsFromUserInfoEndpoint = true;
        options.MapInboundClaims = false; // Remove Microsoft mappings
        options.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = "name"
        };
    })
    .AddCookie();

Note: You could also use the OpenIddict client packages to implement the client. I like to use the defaults.

Step 3: Disable WASM mode

Any web application should protect the session, not just implement authentication using an OIDC server. One of the most important browser protection is the CSP header and a good CSP uses a nonce. Blazor Web using WASM does not support this and so this must be disabled. Remove the WASM part from the middleware.

In the program.cs, update Blazor Web to:

builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

and

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddAdditionalAssemblies(
            typeof(BlazorWebAppOidc.Client._Imports).Assembly);

Remove the WASM usage in the UI components. Switch to InteractiveServer mode.

    <HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
    <Routes @rendermode="InteractiveServer" />

Step 4: Add CSP nonce middleware

The CSP nonce can be used in Blazor (Server) components with some extra effort because the Blazor components cannot read the HTTP headers from the responses. The CircuitHandler class can be used for this. A BlazorNonceService class can be created to add the nonce. This class inherits the CircuitHandler implementation.

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Server.Circuits;

namespace BlazorWebAppOidc.CspServices;

/// <summary>
/// Original src: https://github.com/javiercn/BlazorWebNonceService
/// </summary>
public class BlazorNonceService : CircuitHandler, IDisposable
{
    private readonly PersistentComponentState _state;
    private readonly PersistingComponentStateSubscription _subscription;

    public BlazorNonceService(PersistentComponentState state)
    {
        if (state.TryTakeFromJson("nonce", out string? nonce))
        {
            if (nonce is not null)
            {
                Nonce = nonce;
            }
            else
            {
                throw new InvalidOperationException(
                         "Nonce can't be null when provided");
            }
        }
        else
        {
            _subscription = state.RegisterOnPersisting(PersistNonce);
        }

        _state = state;
    }

    public string? Nonce { get; set; }

    private Task PersistNonce()
    {
        _state.PersistAsJson("nonce", Nonce);
        return Task.CompletedTask;
    }

    public void SetNonce(string nonce)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(nonce);

        if (Nonce != null)
        {
            throw new InvalidOperationException("Nonce already defined");
        }

        Nonce = nonce;
    }

    public void Dispose() => ((IDisposable)_subscription)?.Dispose();
}

A NonceMiddleware ASP.NET Core middleware service can now be used to read the nonce from the headers and set this in the BlazorNonceService CircuitHandler implementation. NetEscapades.AspNetCore.SecurityHeaders is used to implement the security headers and if a CSP nonce is created, the NETESCAPADES_NONCE http header is set.

namespace BlazorWebAppOidc.CspServices;

public class NonceMiddleware
{
    private readonly RequestDelegate _next;

    public NonceMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context, 
              BlazorNonceService blazorNonceService)
    {
        var success = context.Items
                  .TryGetValue("NETESCAPADES_NONCE", out var nonce);
        if (success && nonce != null)
        {
            blazorNonceService.SetNonce(nonce.ToString()!);
        }
        await _next.Invoke(context);
    }
}

The middleware for the nonce is added to the ASP.NET Core services.

builder.Services.TryAddEnumerable(
     ServiceDescriptor.Scoped<CircuitHandler, BlazorNonceService>(sp =>
     sp.GetRequiredService<BlazorNonceService>()));

builder.Services.AddScoped<BlazorNonceService>();

Use the middleware is in the ASP.NET Core pipelines.

app.UseMiddleware<NonceMiddleware>();

Step 5: Add HTTP browser security headers

The NetEscapades.AspNetCore.SecurityHeaders nuget package is used to implement the security headers as best possible for this type of application. The SecurityHeadersDefinitions class implements this. CSP nonces are configuration as well as other security headers.

namespace BlazorWebAppOidc;

public static class SecurityHeadersDefinitions
{
    public static HeaderPolicyCollection GetHeaderPolicyCollection(
        bool isDev, string? idpHost)
    {
        ArgumentNullException.ThrowIfNull(idpHost);

        var policy = new HeaderPolicyCollection()
            .AddFrameOptionsDeny()
            .AddContentTypeOptionsNoSniff()
            .AddReferrerPolicyStrictOriginWhenCrossOrigin()
            .AddCrossOriginOpenerPolicy(builder => builder.SameOrigin())
            .AddCrossOriginResourcePolicy(builder => builder.SameOrigin())
            .AddCrossOriginEmbedderPolicy(builder => builder.RequireCorp())
            .AddContentSecurityPolicy(builder =>
            {
                builder.AddObjectSrc().None();
                builder.AddBlockAllMixedContent();
                builder.AddImgSrc().Self().From("data:");
                builder.AddFormAction().Self().From(idpHost);
                builder.AddFontSrc().Self();
                builder.AddBaseUri().Self();
                builder.AddFrameAncestors().None();

                builder.AddStyleSrc()
                    .UnsafeInline()
                    .Self();

                // due to Blazor
                builder.AddScriptSrc()
                      .WithNonce()
                      .UnsafeEval() // due to Blazor WASM
                      .StrictDynamic()
                      .OverHttps()
                      .UnsafeInline(); // only a fallback for older browsers
            })
            .RemoveServerHeader()
            .AddPermissionsPolicy(builder =>
            {
                builder.AddAccelerometer().None();
                builder.AddAutoplay().None();
                builder.AddCamera().None();
                builder.AddEncryptedMedia().None();
                builder.AddFullscreen().All();
                builder.AddGeolocation().None();
                builder.AddGyroscope().None();
                builder.AddMagnetometer().None();
                builder.AddMicrophone().None();
                builder.AddMidi().None();
                builder.AddPayment().None();
                builder.AddPictureInPicture().None();
                builder.AddSyncXHR().None();
                builder.AddUsb().None();
            });

        if (!isDev)
        {
            // maxage = one year in seconds
            policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains();
        }

        policy.ApplyDocumentHeadersToAllResponses();

        return policy;
    }
}

The security headers are added using middleware as early as possible in the pipeline. I add the headers for all requests.

app.UseSecurityHeaders(
    SecurityHeadersDefinitions.GetHeaderPolicyCollection(
        app.Environment.IsDevelopment(),
        app.Configuration["OpenIDConnectSettings:Authority"]));

The CSP nonce can now be used in the Blazor components and scripts can only be read using the nonce. Unsecure scripts or unsecure inline scripts should never be read anywhere in a browser application.

<pre class="wp-block-syntaxhighlighter-code"> <a href="http://_framework/blazor.web.js">http://_framework/blazor.web.js</a>
</body>
</html>

@code
{
    /// <summary>
    /// Original src: https://github.com/javiercn/BlazorWebNonceService
    /// </summary>
    [CascadingParameter] HttpContext Context { get; set; } = default!;

    protected override void OnInitialized()
    {
        var nonce = GetNonce();
        if (nonce != null)
        {
            BlazorNonceService.SetNonce(nonce);
        }
    }

    public string? GetNonce()
    {
        if (Context.Items.TryGetValue("nonce", out var item) 
            && item is string nonce and not null)
        {
            return nonce;
        }

        return null;
    }
}</pre>

The applications can be started and the authentication and the session protection can be validated. Using the WASM mode in Blazor Web requires a weaker security setup and you need to disable the CSP nonces. This is not a good idea.

Links

https://github.com/dotnet/blazor-samples/tree/main/8.0/BlazorWebAppOidc

https://github.com/damienbod/BlazorServerOidc

4 comments

  1. […] Implement a secure Blazor Web application using OpenID Connect and security headers (Damien Bowden) […]

  2. […] Implement a secure Blazor Web application using OpenID Connect and security headers – Damien Bowden […]

  3. Jihed Halimi · · Reply

    Thanks for your articles! Can we implement a secure Blazor web application using OpenId Connect and WebAssembly (Without disabling WASM Mode)? Server side Blazor uses SignalR channels which are not very scalable

    1. Hi Jihed, thanks, OIDC will work without problem, if you use WASM, then you need to disable the CSP nonce protection and weaken the session protection. This is a trade-off, all depends which is more important to you and if you can live with the weak CSP, greetings Damien

Leave a comment

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