Blazor Workbox - Project and Sitecore ID Server Setup

Blazor Workbox - Project and Sitecore ID Server Setup

Blazor Workbox Series Part 1 - Integrate Blazor WebAssembly standalone application with Sitecore Identity Server and use it as a custom Sitecore app.

Recently, I was exploring a way to create custom apps for Sitecore using Blazor WebAssembly. Someone might ask why, given the existence of the SPEAK framework with its Business Component Library, which in its latest version is supported by the Angular framework. Personally, I much prefer dealing with .NET Core apps and C# overall. By utilizing the powerful Sitecore GraphQL Authoring and Management API in a standalone Blazor WebAssembly, backed by a modern Blazor UI toolkit such as Radzen, we can rapidly build rich web apps that interact with the Sitecore ecosystem.

This blog series focuses on building a new, improved Sitecore Workbox from scratch. My intention is to show, through several articles, how to set up the Blazor project step-by-step, integrate it with the GraphQL API, build the Workbox UI with all relevant filters, and finally, build logic around executing workflow commands. Personally, I enjoyed this a lot and find Blazor WebAssembly to be a no-brainer for building custom Sitecore apps whenever C# is preferred.

ID Server Setup

Given that we are going to utilize the GraphQL Management and Authoring API later, we need to integrate our Blazor app with the Sitecore Identity Server. This integration will also allow us to retrieve information about user claims and easily implement role-based access control.

The only step required is to set up a client in the Sitecore Identity Server configuration file, for example in <IdentityServerRoot>\Config\production\Sitecore.IdentityServer.Host.xml

The following client configuration should be added under the <Clients> node. Feel free to modify the access and identity token lifetimes and the allowed CORS origins (localhost:7240 is my app's default host in the local environment). It is worth mentioning that the only grant type supported by Blazor WebAssembly is Proof Key for Code Exchange (PKCE) authorization code flow.

<BlazorWorkboxClient>
  <ClientId>BlazorWorkbox</ClientId>
  <ClientName>BlazorWorkbox</ClientName>
  <AccessTokenLifetimeInSeconds>86400</AccessTokenLifetimeInSeconds>
  <IdentityTokenLifetimeInSeconds>86400</IdentityTokenLifetimeInSeconds>
  <AllowedGrantTypes>
     <AuthorizationCode>authorization_code</AuthorizationCode>
  </AllowedGrantTypes>
  <RedirectUris>
      <RedirectUri1>{AllowedCorsOrigin}/authentication/login-callback</RedirectUri1>
  </RedirectUris>
  <AllowedCorsOrigins>
        <Local>https://localhost:7240</Local>
  </AllowedCorsOrigins>
  <AllowedScopes>
    <AllowedScope1>openid</AllowedScope1>
    <AllowedScope2>sitecore.profile</AllowedScope2>
    <AllowedScope3>sitecore.profile.api</AllowedScope3>
  </AllowedScopes>
</BlazorWorkboxClient>

WebAssembly project setup

As the next step, let's create a blank project called BlazorWorkbox using the Blazor WebAssembly Standalone App template. We are going to set up authentication from scratch, so set the Authentication type to None and use the other settings as shown in the screenshot below.

Adding Authentication

To setup the app with an authentication framework that handles OIDC protocol communication with the Sitecore Identity Server, we need to add the necessary dependency and set up the application settings.

Open a terminal or command prompt in the root directory of BlazorWorkbox project and run the following command to add the required package:

dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Authentication

Before we set up the app to use specific OIDC provider options, let's create an application settings model and a related JSON file that we are going to utilize. We need to add an appsettings.json file to the wwwroot folder. For now, we will keep the base URL of the Identity Server there as shown below:

{
  "AppSettings": {
    "IdentityAuthorityBaseUrl": "https://<id_hostname>",
  }
}

Another step is to create the AppSettings.cs model to which the values will be bound. Let's place it in the Models folder.

namespace BlazorWorkbox.Models
{
    public class AppSettings
    {
        public string IdentityAuthorityBaseUrl { get; set; }
    }
}

Finally, let's configure the app in the Program.cs file. The following fragment is a typical part related to Blazor Web Assembly apps - creation of WebAssemblyHostBuilder and setting-up starting node for the Blazor renderer.

using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using BlazorWorkbox;
using BlazorWorkbox.Models;

var builder = WebAssemblyHostBuilder.CreateDefault(args);

builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

The following part is responsible for binding the appsettings.json options to our model, which we will use to set up the OIDC provider options. We will match settings like client ID, grant type, and scopes to the ones that we defined earlier for the Identity Server client. This is the most simplistic configuration.

AppSettings appSettings = new AppSettings();
builder.Configuration.GetSection("AppSettings").Bind(appSettings);

builder.Services.AddOidcAuthentication(options =>
{
    options.ProviderOptions.Authority = appSettings.IdentityAuthorityBaseUrl;
    options.ProviderOptions.ClientId = "BlazorWorkbox";
    options.ProviderOptions.ResponseType = "code";
    options.ProviderOptions.DefaultScopes.Clear();
    options.ProviderOptions.DefaultScopes.Add("openid");
    options.ProviderOptions.DefaultScopes.Add("sitecore.profile");
    options.ProviderOptions.DefaultScopes.Add("sitecore.profile.api");
});

The last three lines of Program.cs below are:

  • To setup a custom SitecoreAccountClaimsPrincipalFactory that is required to correctly bind/convert Sitecore roles to user claims (we will revisit this shortly).

  • To configure our AppSettings options in the Dependency Injection container so that we can easily use them in custom components or services.

  • Finally, to build the WebAssemblyHostBuilder.

builder.Services.AddScoped(typeof(AccountClaimsPrincipalFactory<RemoteUserAccount>), typeof(SitecoreAccountClaimsPrincipalFactory));

builder.Services.Configure<AppSettings>(x => builder.Configuration.GetSection("AppSettings").Bind(x));

await builder.Build().RunAsync();

As mentioned earlier, we need to implement a custom SitecoreAccountClaimsPrincipalFactory to correctly bind Sitecore roles to user claims. This necessity arises due to the format in which Sitecore roles are received, which is described in detail in the ticket here.

In essence, Sitecore roles are transmitted as a JSON array that is serialized into a string, for instance: "[sitecore\Author","sitecore\Sitecore Client Authoring","sitecore\Sitecore Client Users"]". Our task is to transform each individual role into a role claim type. Additionally, we'll convert an incoming isAdmin claim into an Admin role claim type. This transformation will allow us to restrict access to specific Blazor pages or components using attributes like [Authorize(Roles = "sitecore\Author, Admin")].

Let's proceed by creating an SitecoreAccountClaimsPrincipalFactory.cs file in the root folder of the project with the necessary implementation.

using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;

namespace BlazorWorkbox
{
    public class SitecoreAccountClaimsPrincipalFactory : AccountClaimsPrincipalFactory<RemoteUserAccount>
    {
        private const string AdminClaimType = "http://www.sitecore.net/identity/claims/isAdmin";
        private const string SitecoreRoleClaimType = "role";

        public SitecoreAccountClaimsPrincipalFactory(IAccessTokenProviderAccessor accessor): base(accessor)
        {
        }

        public async override ValueTask<ClaimsPrincipal> CreateUserAsync(RemoteUserAccount account, RemoteAuthenticationUserOptions options)
        {
            ClaimsPrincipal userAccount = await base.CreateUserAsync(account, options);
            var userIdentity = (ClaimsIdentity)userAccount.Identity;

            if (userIdentity.IsAuthenticated)
            {

                if (account.AdditionalProperties.TryGetValue(SitecoreRoleClaimType, out var roles))
                {
                    if (roles is JsonElement jsonRoles && jsonRoles.ValueKind == JsonValueKind.Array)
                    {
                        userIdentity.TryRemoveClaim(userIdentity.Claims.FirstOrDefault(c => c.Type == SitecoreRoleClaimType));
                        IEnumerable<Claim> claims = jsonRoles.EnumerateArray().Select(x => new Claim(ClaimTypes.Role, x.ToString().Replace("\\\\", "\\")));
                        userIdentity.AddClaims(claims);
                    }
                }

                if (account.AdditionalProperties.TryGetValue(AdminClaimType, out var admin) && admin is JsonElement adminJson && adminJson.ValueEquals("True"))
                {
                    userIdentity.AddClaim(new Claim(ClaimTypes.Role, "Admin"));
                }
            }

            return userAccount;
        }
    }
}

As the next step, we need to create a Blazor page in the Pages folder called Authentication.razor. This page will be responsible for handling communication between the app and Identity Server. It's important to note that we have defined the routing for this page as /authentication/{action}, which corresponds to the <RedirectUri> configured in the Identity Server client settings {AllowedCorsOrigin}/authentication/login-callback.

The Authentication.razor page includes a crucial RemoteAuthenticatorView component. This component manages all authentication actions and allows us to define custom messages that appear in the page body based on the authentication phase.

@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

<RemoteAuthenticatorView Action="@Action" />

@code {
    [Parameter] public string? Action { get; set; }
}

Let's examine the default App.razor file located in the root of the project. Since our project template was created without any authentication enabled, we need to change the <Found> router to the following structure, which includes <AuthorizeRouteView> components. This setup handles:

  • Redirection to the login page (in our case, the Identity Server login page) if the user is not authenticated.

  • Displaying a message if the user is authenticated but not authorized due to role restrictions.

    <Found Context="routeData">
        <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
            <NotAuthorized>
                @if (context.User.Identity?.IsAuthenticated == false)
                {
                    <RedirectToLogin />
                }
                else
                {
                    <p role="alert">You are not authorized to access this resource.</p>
                }
            </NotAuthorized>
        </AuthorizeRouteView>
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
    </Found>

Next, we need to add two @using directives to the Blazor global import file named _Imports.razor

@using BlazorWorkbox.Components
@using Microsoft.AspNetCore.Components.Authorization

With these @using directives added, RedirectToLogin and AuthorizeRouteView will be recognized in the App.razor file.

The RedirectToLogin component is responsible for redirects to the login page during component’s initialization. It utilizes injected NavigationManager service, which is one of the built-in Blazor services. Let’s create Components folder and place inside RedirectToLogin.razor with following piece of code:

@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject NavigationManager Navigation

@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateToLogin("authentication/login");
    }
}

Last but not least, to ensure the authentication framework is working, we need to add a reference to the related JavaScript code that is shipped together with the Microsoft.AspNetCore.Components.WebAssembly.Authentication NuGet package, which we have already added as a dependency to the project. Simply add the following line to the wwwroot/index.html file before the closing </body> tag:

<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></script>

GloablHeader Component

Since BlazorWorkbox is intended to be a custom Sitecore application, it would be good to maintain a UI design similar to the original Sitecore interface, as depicted in the screen below.

To achieve this, we will create a GlobalHeader.razor component in the Components folder. This component will:

  • Display a button in the top-left corner that redirects to the Sitecore Dashboard.

  • Show a "Log out" button.

  • Display the name of the logged-in user.

For linking to the Sitecore Dashboard, we can define the base URL of Sitecore by adding a new setting to the appsettings.json file and updating the AppSettings.cs model accordingly.

"ContentManagementInstanceBaseUrl": "https://<cm_hostname>"
public string ContentManagementInstanceBaseUrl { get; set; }

The HTML structure of the GlobalHeader.razor component will resemble the Sitecore interface. To achieve this, references to the original Sitecore CSS classes are included for styling consistency. This approach ensures that the UI of BlazorWorkbox closely mirrors the design and aesthetics of the Sitecore platform.

@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Microsoft.Extensions.Options
@using BlazorWorkbox.Models

<link type="text/css" rel="stylesheet" href="@AppSettings.Value.ContentManagementInstanceBaseUrl/sitecore/shell/themes/standard/default/Default.css" />
<link type="text/css" rel="stylesheet" href="@AppSettings.Value.ContentManagementInstanceBaseUrl/sitecore/shell/Themes/Standard/Default/GlobalHeader.css" />

<div id="globalHeader" class="sc-globalHeader">
    <div class="sc-globalHeader-content">
        <div class="col2">
            <div class="sc-globalHeader-startButton">
                <a href="@AppSettings.Value.ContentManagementInstanceBaseUrl/sitecore/shell/sitecore/client/Applications/Launchpad" id="globalLogo" class="sc-global-logo">&nbsp;</a>
            </div>
        </div>
        <div class="col2">
            <div class="sc-globalHeader-loginInfo">
                <AuthorizeView>
                    <Authorized>
                        <ul class="sc-accountInformation">
                            <li><span class="logout" onclick="@BeginLogOut">Log out</span></li>
                            <li>
                                <span id="globalHeaderUserName">@context.User.Identity?.Name?.Split('\\').ElementAtOrDefault(1)</span>
                            </li>
                        </ul>
                    </Authorized>
                </AuthorizeView>
            </div>
        </div>
    </div>
</div>

@code {
    [Inject]
    private NavigationManager Navigation { get; set; }

    [Inject]
    private IOptions<AppSettings> AppSettings { get; set; }

    public void BeginLogOut()
    {
        Navigation.NavigateToLogout("authentication/logout");
    }
}

Main Layout and Home Page

The main container of all our pages is the MainLayout.razor file that is kept in Layout folder. Here, we define the <GlobalHeader /> component followed by a @Body placeholder where the content of each page will be rendered. This structure ensures that every page within the application includes the global header component and displays its specific content in the designated area.

@inherits LayoutComponentBase

<GlobalHeader />
<main>
    @Body
</main>

In the same location, we can place a MainLayout.razor.css file. This file will contain basic CSS responsible for pushing the body content down a bit. This adjustment is necessary because the default Sitecore header CSS includes position: fixed, which fixes it at the top of the viewport.

main {
    position: relative;
    top: 80px;
    margin: 0 20px;
}

The CSS Razor files are concatenated all together into one single CSS file following naming convention <projectName>.styles.css. We need to reference BlazorWorkbox.styles.css in wwwroot/index.html in <head> section.

    <link href="BlazorWorkbox.styles.css" rel="stylesheet" />

There is also one CSS file that is referenced by default in index.html - wwwroot/css/app.css file. It contains stylesheet related to the Blazor framework and is responsible for styling for example error messages or initial preloaded. Let’s add there one entry that will make the percentages inside the preloader to render using sans-serif font family.

* {
    font-family: sans-serif;
}

Let's have a look now into our home page Razor file located in Pages/Home.razor. Here we define the routing value using @page "/" and allow access to users that are admins or belong to sitecore\Author role. Built-in <PageTitle> Razor component updates <title> HTML tag in main layout and finally we can put some welcome content to our home page.

@using Microsoft.AspNetCore.Authorization

@page "/"
@attribute [Authorize(Roles = "sitecore\\Author, Admin")]

<PageTitle>Blazor Workbox</PageTitle>

<h1>Welcome to Blazor Workbox homepage!</h1>

Now, our project structure should look like below:

After launching the application in Visual Studio, we should be redirected to Sitecore ID Server login page. After entering our credentials we should finally see our Blazor Workbox home page.

Visit Blazor Workbox Series Part 1 @GitHub to see complete source code.

Summary

We have created Blazor WASM project from scratch that is capable of authenticating users by interacting with Sitecore Identity Server. We have also setup the app to use custom appsettings and defined key layout elements that will be the foundation for next steps.

As next step we will integrate our app with Sitecore Authoring and Management API, define simple query that will fetch some Sitecore items and finally render them on the page on data-grid component using Radzen UI toolkit.