Securing a Blazor Server application using OpenID Connect and security headers

This article shows how to secure a Blazor Server application. The application implements an OpenID Connect confidential client with PKCE using .NET 8 and configures the security headers as best possible for the Blazor Server application. OpenIddict is used to implement the identity provider and the OpenID Connect server.

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

OpenID Connect flow

In the first step, the authentication can be solved using OpenID Connect. With this, the process of user authentication is removed from the client application and delegated to an identity provider. In this demo, OpenIddict is used. The OpenID Connect code flow with PKCE is used and the application uses a client secret to authenticate. This can be further improved by using a certificate and client assertions when using the code from the OpenID Connect flow to request the tokens. The flow can also be improved to use OAuth 2.0 Pushed Authorization Requests PAR, if the identity provider supports this.

In ASP.NET Core or Blazor, this can be implemented using the Microsoft ASP.NET Core OpenIdConnect package.

  • Microsoft.AspNetCore.Authentication.OpenIdConnect

The authentication flow requires some service setup as well as middleware changes in the pipelines. The solution uses the OpenID Connect scheme to take care of the user authentication and saves this in a cookie. The name claim is set to use the “name” claim. This is different with every identity provider and also depending how the ASP.NET Core application maps this. All razor page requests must be authenticated unless otherwise specified.

builder.Services.AddAuthentication(options =>
{
  options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
  options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
  builder.Configuration.GetSection("OpenIDConnectSettings").Bind(options);

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

  options.SaveTokens = true;
  options.GetClaimsFromUserInfoEndpoint = true;
  options.TokenValidationParameters = new TokenValidationParameters
  {
	  NameClaimType = "name"
  };
});

builder.Services.AddRazorPages().AddMvcOptions(options =>
{
  var policy = new AuthorizationPolicyBuilder()
	  .RequireAuthenticatedUser()
	  .Build();
  options.Filters.Add(new AuthorizeFilter(policy));
});

builder.Services.AddControllersWithViews(options =>
    options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()));

The application pipeline is setup after the services are built. ASP.NET Core adds some automatic claim mapping and renames some claims per default. This can be reset to use the exact claims sent back from the identity provider.

var app = builder.Build();

JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

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

app.UseHttpsRedirection();

app.UseStaticFiles();

app.UseRouting();

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

app.MapRazorPages();
app.MapControllers();

app.MapBlazorHub().RequireAuthorization();
app.MapFallbackToPage("/_Host");

app.Run();

CascadingAuthenticationState

The CascadingAuthenticationState is used to force and share the authentication requirements in the UI and the Blazor Server components. If the application and the user are not authenticated and authorized, the user is redirected to the identity provider.

@inject NavigationManager NavigationManager

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(App).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @{
                        var returnUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
                        NavigationManager.NavigateTo($"api/account/login?redirectUri={returnUrl}", forceLoad: true);
                    }
                </NotAuthorized>
                <Authorizing>
                    Wait...
                </Authorizing>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Authentication flow UI Pages

The menu needs to display the login or the logout buttons depends on the state of the application. Blazor provides an AuthorizeView component for this. This can be used in the UI to hide or show elements depending on the authenticate state.

<AuthorizeView>
    <Authorized>
        <span>@context.User.Identity?.Name</span>
        <form action="api/account/logout" method="post">
            <button type="submit" class="nav-link btn btn-link text-dark">
                 Logout 
            </button>
        </form>
    </Authorized>
    <NotAuthorized>
        <a href="api/account/login?redirectUri=/">Log in</a>
    </NotAuthorized>
</AuthorizeView>

I added a log in, log out in an account controller and signed out Razor page to handle the authentication requests from the UI. The application automatically authenticates so the login is only used in the signed out page. The login removes the authenticated user from both schemes; the cookie and the OpenID Connect scheme. To remove the authenticated user from the identity provider, the application needs to redirect. This cannot be implemented in an ajax request.

[IgnoreAntiforgeryToken] // need to apply this to the form post request
[Authorize]
[HttpPost("Logout")]
public IActionResult Logout()
{
    return SignOut(
        new AuthenticationProperties { RedirectUri = "/SignedOut" },
        CookieAuthenticationDefaults.AuthenticationScheme,
        OpenIdConnectDefaults.AuthenticationScheme);
}

The SignedOut is the only unauthorized page, component in the application. The AllowAnonymous is used for this.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace BlazorServerOidc.Pages;

[AllowAnonymous]
public class SignedOutModel : PageModel
{
    public void OnGet() { }
}

Security headers

The user is authenticated. The application need to protect the session as well. I use NetEscapades.AspNetCore.SecurityHeaders for this.

  • NetEscapades.AspNetCore.SecurityHeaders
  • NetEscapades.AspNetCore.SecurityHeaders.TagHelpers

Blazor Server is a severed rendered application with UI components. The application can implement nonce based CSP. All the typical security headers are added as best possible for this technology.

namespace BlazorServerOidc;

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()) // remove for dev if using hot reload
            .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();

                builder.AddScriptSrc()
                    .WithNonce()
                    .UnsafeInline(); // only a fallback for older browsers when the nonce is used

                // disable script and style CSP protection if using Blazor hot reload
                // if using hot reload, DO NOT deploy with an insecure CSP
            })
            .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 CSP nonces are added to all scripts in the UI.

@using Microsoft.AspNetCore.Components.Web
@namespace BlazorServerOidc.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, NetEscapades.AspNetCore.SecurityHeaders.TagHelpers

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
    <link href="BlazorServerOidc.styles.css" rel="stylesheet" />
    <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head>
<body>
    @RenderBody()

    <div id="blazor-error-ui">
        <environment include="Staging,Production">
            An error has occurred. This application may no longer respond until reloaded.
        </environment>
        <environment include="Development">
            An unhandled exception has occurred. See browser dev tools for details.
        </environment>
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>

    <script asp-add-nonce src="_framework/blazor.server.js"></script>
</body>
</html>

Next steps

In a follow up blog, I would like to implement the same type of security for the new .NET 8 Blazor web applications.

Links

https://learn.microsoft.com/en-us/aspnet/core/blazor/security/server/

https://learn.microsoft.com/en-us/aspnet/core/blazor/security/server/interactive-server-side-rendering

https://learn.microsoft.com/en-us/power-apps/developer/data-platform/webapi/quick-start-blazor-server-app

https://stackoverflow.com/questions/64853618/oidc-authentication-in-server-side-blazor

https://github.com/openiddict/openiddict-core

https://datatracker.ietf.org/doc/html/rfc9126

https://learn.microsoft.com/en-us/aspnet/core/security/authentication/claims

2 comments

  1. […] Securing a Blazor Server application using OpenID Connect and security headers (Damien Bowden) […]

  2. […] Securing a Blazor Server application using OpenID Connect and security headers […]

Leave a comment

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