Securing Blazor Server App using IdentityServer4

This is a recipe for setting up a Blazor Server App with authentication. We’ll look at:

  • How you setup Login/Logout buttons
  • How to change the menu depending on whether you are already authorized or not
  • How to get the [Authorize] attribute to work
  • How to use the antiforgery token

I assume you already have IdentityServer4 up and running, and that you have a client that you can use.

This is just a recipe. For understanding the concepts, I recommend Kevin Dockx’ course “Securing Blazor Server-side Applications” on Pluralsight.

You can also find useful information here: https://docs.microsoft.com/en-us/aspnet/core/blazor/security/server/additional-scenarios?view=aspnetcore-3.1

The code I used to test this can be found on GitHub:https://github.com/ArveH/TestSecuringBlazor

Create project

Create a new .NET Core 3.1 Blazor App project. Choose “No Authentication”

Add the following NuGet packages:

  • Microsoft.AspNetCore.Authentication.OpenIdConnect
  • Microsoft.AspNetCore.Components.Authorization (for using Authentication with Blazor views)

Add the following code in the ConfigureServices method in Startup.cs:

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme,
        options =>
        {
            options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
            options.SignOutScheme = OpenIdConnectDefaults.AuthenticationScheme;
            options.Authority = Configuration["oidc:Authority"];
            options.ClientId = Configuration["oidc:ClientId"];
            options.ClientSecret = Configuration["oidc:ClientSecret"];
            
            // When set to code, the middleware will use PKCE protection
            options.ResponseType = "code";
            
            // The "openid" and "profile" scopes are added automatically,
            // so we don't need to add them for this test
            //options.Scope.Add("openid");
            //options.Scope.Add("profile");

            // Save the tokens we receive from the IDP
            options.SaveTokens = true;

            // It's recommended to always get claims from the 
            // UserInfoEndpoint during the flow. 
            options.GetClaimsFromUserInfoEndpoint = true;
        });

Notice the application settings you have to provide:

  • oidc:Authority
  • oidc:ClientId
  • oidc:ClientSecret

Add UseAuthentication and UseAuthorization to the Configure method in Startup.cs. Make sure you place them between UseRouting and UseEndpoints:

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

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

app.UseEndpoints(endpoints =>
{
    endpoints.MapBlazorHub();
    endpoints.MapFallbackToPage("/_Host");
});

Create a Login Page

Add a new regular Razor page in the Pages folder and call it Login. This page doesn’t need any UI, since asking for user name and password handled by the Identity Provider. We just need to do a “challenge”.

Change the code in the LoginModel class to this:

public class LoginModel : PageModel
{
    public async Task<IActionResult> OnGetAsync(string redirectUri)
    {
        // just to remove compiler warning
        await Task.CompletedTask;

        if (string.IsNullOrWhiteSpace(redirectUri))
        {
            redirectUri = Url.Content("~/");
        }
        // If user is already logged in, we can redirect directly...
        if (HttpContext.User.Identity.IsAuthenticated)
        {
            Response.Redirect(redirectUri);
        }

        return Challenge(
            new AuthenticationProperties
            {
                RedirectUri = redirectUri
            },
            OpenIdConnectDefaults.AuthenticationScheme);
    }
}

The documentation for Bazor states that you can’t rely on the HttpContext, but since this is a normal Razor page (not a Blazor component), we can use it here.

In the In the NavMenu.razor file, we add a menu item for Login, and wrap everything with an <AutherizeView> tag. We can now choose what to display when we are authorized, and what to display when we’re not :

<AuthorizeView>
    <Authorized>
        <li class="nav-item px-3">

            <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                <span class="oi oi-home" aria-hidden="true"></span> Home
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="counter">
                <span class="oi oi-plus" aria-hidden="true"></span> Counter
            </NavLink>
        </li>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="fetchdata">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
            </NavLink>
        </li>
    </Authorized>
    <NotAuthorized>
        <li class="nav-item px-3">
            <NavLink class="nav-link" href="login">
                <span class="oi oi-list-rich" aria-hidden="true"></span> Login
            </NavLink>
        </li>
    </NotAuthorized>
</AuthorizeView>

For this to work, we also need an AuthenticationState. In the App.razor file, wrap the Router with a <CascadingAuthenticationState>:

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Create Logout Page

Add a new regular razor page and call it Logout. This page doesn’t need any UI either, so just change the LogoutModel:

public class LogoutModel : PageModel
{
    public async Task<IActionResult> OnGetAsync()
    {
        // just to remove compiler warning
        await Task.CompletedTask;

        return SignOut(
            new AuthenticationProperties
            {
                RedirectUri = "https://localhost:44388"
            },
            OpenIdConnectDefaults.AuthenticationScheme,
            CookieAuthenticationDefaults.AuthenticationScheme);
    }
}

And add a Logout button to the menu:

        <AuthorizeView>
            <Authorized>
                <li class="nav-item px-3">

                    <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
                        <span class="oi oi-home" aria-hidden="true"></span> Home
                    </NavLink>
                </li>
                <li class="nav-item px-3">
                    <NavLink class="nav-link" href="counter">
                        <span class="oi oi-plus" aria-hidden="true"></span> Counter
                    </NavLink>
                </li>
                <li class="nav-item px-3">
                    <NavLink class="nav-link" href="fetchdata">
                        <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
                    </NavLink>
                </li>
                <li class="nav-item px-3">
                    <NavLink class="nav-link" href="logout">
                        <span class="oi oi-list-rich" aria-hidden="true"></span> Logout
                    </NavLink>
                </li>
            </Authorized>
            <NotAuthorized>
                <li class="nav-item px-3">
                    <NavLink class="nav-link" href="login">
                        <span class="oi oi-list-rich" aria-hidden="true"></span> Login
                    </NavLink>
                </li>
            </NotAuthorized>
        </AuthorizeView>

Set Authorize Attribute

To prevent unauthorized access to a page, we have to set the Authorize attribute. This is what the start of the Counter page should look like:

@page "/counter"
@attribute [Authorize]

<h1>Counter</h1>

For the attribute to work, we also have to change from the RouteView to the AuthorizeRouteView in the App.razor page.

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

Test that it works

Compile and run application. I have changed my launchSettings.cs file, so I use SSL and run on port 44388:

{
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "https://localhost:44388",
      "sslPort": 44388
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "TestSecuringBlazor": {
      "commandName": "Project",
      "launchBrowser": true,
      "applicationUrl": "https://localhost:44388",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Only the Login button should show in the left Menu:

Make sure you can’t open the Counter page. Type https://localhost:44388/counter in the address bar:

When you login, you should see the full menu:

Logout and check that all cookies are removed:

From GET to POST

If you want to change the Login and/or Logout request from GET to POST (e.g. preventing brute-force replay attacks), you need to make sure you’re not vulnerable to XSRF (Cross-site Request Forgery) attacks. To protected against this, ASP.NET Core use an Antiforgery token. In a “normal” ASP.NET Core web application, everything is handled automatically for you when you set the [ValidateAntiforgeryToken] attribute on the controller. In a Blazor app, it’s not. You will have to handle the antiforgery token yourself. The recommended way of doing this is to create a TokenProvider. (see Pass tokens to a Blazor Server app). It will eventually be used to set the hidden field __RequestVerificationToken in the NavMenu component.

TokenProvider and InitialState

public class InitialApplicationState
{
    public string XsrfToken { get; set; }
}
public class TokenProvider
{
    public string XsrfToken { get; set; }
}

Add the following services to ConfigServices,cs:

services.AddScoped<TokenProvider>();

In _Host.cshtml we inject the IAntiForgery interface, create the token, then pass the token to the Blazor app using the param tag:

@page "/"
@namespace TestSecuringBlazor.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
    Layout = null;
}
@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Xsrf;

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>TestSecuringBlazor</title>
    <base href="~/" />
    <link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
    <link href="css/site.css" rel="stylesheet" />
</head>
<body>
    @{
        var tokens = new InitialApplicationState
        {
            XsrfToken = Xsrf.GetAndStoreTokens(HttpContext).RequestToken
        };
    }
    <app>
        <component type="typeof(App)" render-mode="ServerPrerendered" 
                   param-InitialState="tokens"/>
    </app>
...

In the Blazor App, we can now inject the TokenProvider:

@inject TokenProvider TokenProvider

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(MainLayout)">
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

@code {
    [Parameter]
    public InitialApplicationState InitialState { get; set; }

    protected override Task OnInitializedAsync()
    {
        TokenProvider.XsrfToken = InitialState.XsrfToken;

        return Task.CompletedTask;
    }
}

We could also add the id_token and access_token to the TokenProvider if needed.

Change Logout to POST

We can now change to POST. In Logout.cshtml.cs, add the OnPostAsync action:

public async Task<IActionResult> OnPostAsync()
{
    await Task.CompletedTask; // just to remove warning

    return SignOut(
        new AuthenticationProperties
        {
            RedirectUri = "https://localhost:44388"
        },
        OpenIdConnectDefaults.AuthenticationScheme,
        CookieAuthenticationDefaults.AuthenticationScheme);
}

In the NavMenu.razor file, inject the TokenProvider and add a link for the new Logout. This is where you need the TokenProvider to get the antiforgery token:

@inject TokenProvider TokenProvider
...
<li class="nav-item px-3">
    <NavLink class="nav-link" href="logout">
        <span class="oi oi-list-rich" aria-hidden="true"></span> Logout
    </NavLink>
</li>
<li class="nav-item px-3">
    <form action="/Logout" method="post">
        <NavLink class="nav-link" href="logout" onclick="this.closest('form').submit();return false;">
            <span class="oi oi-list-rich" aria-hidden="true"></span> Logout (POST)
        </NavLink>
        <input name="__RequestVerificationToken" type="hidden"
               value="@TokenProvider.XsrfToken" />
    </form>
</li>

Test that it works

Do the same as you did when you testet earlier. The only difference should be that you now see another option for logging out (test this logout), and there is an antiforgery cookie:

2 Comments on “Securing Blazor Server App using IdentityServer4

  1. Hi
    I am new to Blazor and trying to authenticate your example code to my IDS4 project (localhost:5001). My problem is after enter UserName and Password (IS/Account/Login), it returns me to the same login page but not to the client (localhost:44388 as your example project has). Could you please help me with this?

    Here my IDS4 Config (uriWebAdmin=localhost:44388):
    new Client
    {
    ClientId = “bms_webadmin”,
    ClientSecrets = { new Secret(“secret”.Sha256()) },
    ClientUri = $”https://{uriWebAdmin}/”,

    AllowedGrantTypes = GrantTypes.Code,

    // where to redirect to after login
    RedirectUris = { $”https://{uriWebAdmin}/signin-oidc” },

    // where to redirect to after logout
    PostLogoutRedirectUris = { $”https://{uriWebAdmin}/signout-callback-oidc” },

    AllowedScopes = new List
    {
    IdentityServerConstants.StandardScopes.OpenId,
    IdentityServerConstants.StandardScopes.Profile,
    “bms_api”
    }
    }

  2. Hi
    So if you look in browser tools (F12), you see the authorize request to your IDS, but you never get back from IDS to the Blazor app? Did I understand that correctly?
    If that is the case, it sound like your Blazor app is working as it should, and you need to look in your IDS log for clues. There should be some error messages there.

    PS: Sorry for the late reply. I’m actually just using this blog to help me remember things I’ve been having problems with. Not used to comments 🙂