Blazor WebAssembly with Cookie Authentication

In the Backend I’ve started by adding Cookie Authentication in the startup and override the OnRedirectToLogin event handlers, so they are going to return a HTTP Status Code 401 to the consumer. This is handled in the Exception Handling Middleware and not shown here.

// Cookie Authentication
builder.Services
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.HttpOnly = true;
        options.Cookie.SameSite = SameSiteMode.Lax; // We don't want to deal with CSRF Tokens

        options.Events.OnRedirectToAccessDenied = context => throw new AuthenticationFailedException();
        options.Events.OnRedirectToLogin = context => throw new AuthenticationFailedException();
    });

The user is signed in using HttpContext#SignInAsync with something along the lines of a AuthenticationController:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

// ...

namespace RebacExperiments.Server.Api.Controllers
{
    public class AuthenticationController : ODataController
    {
        // ...

        [HttpPost("odata/SignInUser")]
        public async Task SignInUser([FromServices] IUserService userService, [FromBody] ODataActionParameters parameters, CancellationToken cancellationToken)
        {
            // ...

            // Create the ClaimsPrincipal
            var claimsIdentity = new ClaimsIdentity(userClaims, CookieAuthenticationDefaults.AuthenticationScheme);
            var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);

            // It's a valid ClaimsPrincipal, sign in
            await HttpContext.SignInAsync(claimsPrincipal, new AuthenticationProperties { IsPersistent = rememberMe });
            // ...
        }
    }
}        

You can then open your Browsers Developer Tools and see, that an (encrypted) Cookie has been created.

Once we have successfully logged in and got our Cookie, we need to send the Authorization Cookie on every request to the API. So we start by adding a CookieDelegatingHandler, that does just that:

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.AspNetCore.Components.WebAssembly.Http;
using RebacExperiments.Blazor.Shared.Logging;

namespace RebacExperiments.Blazor.Infrastructure
{
    public class CookieDelegatingHandler : DelegatingHandler
    {
        private readonly ILogger _logger;

        public CookieDelegatingHandler(ILogger logger)
        {
            _logger = logger;
        }

        protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            _logger.TraceMethodEntry();

            request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);

            return await base.SendAsync(request, cancellationToken);
        }
    }
}

The CookieDelegatingHandler needs to be registered for the HttpClient, so we use the IHttpClientBuilder#AddHttpMessageHandler extension method like this:

builder.Services
    .AddHttpClient(client => client.BaseAddress = new Uri("https://localhost:5000"))
    .AddHttpMessageHandler();

The Blazor Authorization Infrastructure uses an AuthenticationStateProvider to pass the user information into the components. We want to persist the user information across page refreshes, so the local storage of a Browser seems to be a good place to persist it.

We don’t need to take additional dependencies, just write a small LocalStorageService.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.JSInterop;
using System.Text.Json;

namespace RebacExperiments.Blazor.Infrastructure
{
    public class LocalStorageService
    {
        private IJSRuntime _jsRuntime;

        public LocalStorageService(IJSRuntime jsRuntime)
        {
            _jsRuntime = jsRuntime;
        }

        public async Task GetItemAsync(string key)
        {
            var json = await _jsRuntime.InvokeAsync("localStorage.getItem", key);

            if (json == null)
            {
                return default;
            }

            return JsonSerializer.Deserialize(json);
        }

        public async Task SetItem(string key, T value)
        {
            await _jsRuntime.InvokeVoidAsync("localStorage.setItem", key, JsonSerializer.Serialize(value));
        }

        public async Task RemoveItemAsync(string key)
        {
            await _jsRuntime.InvokeVoidAsync("localStorage.removeItem", key);
        }
    }
}

And register it in the Program.cs.

builder.Services.AddSingleton();

We can then implement an AuthenticationStateProvider, that allows us to set a User (think of User Profile) and notify subscribers about the new AuthenticationState. The User is persisted using our LocalStorageService.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.AspNetCore.Components.Authorization;
using RebacExperiments.Shared.ApiSdk.Models;
using System.Security.Claims;

namespace RebacExperiments.Blazor.Infrastructure
{
    public class CustomAuthenticationStateProvider : AuthenticationStateProvider
    {
        private const string LocalStorageKey = "currentUser";

        private readonly LocalStorageService _localStorageService;

        public CustomAuthenticationStateProvider(LocalStorageService localStorageService)
        {
            _localStorageService = localStorageService;
        }

        public override async Task GetAuthenticationStateAsync()
        {
            var currentUser = await GetCurrentUserAsync();

            if(currentUser == null)
            {
                return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
            }

            Claim[] claims = [
                new Claim(ClaimTypes.NameIdentifier, currentUser.Id!.ToString()!),
                new Claim(ClaimTypes.Name, currentUser.LogonName!.ToString()!),
                new Claim(ClaimTypes.Email, currentUser.LogonName!.ToString()!)
            ];

            var authenticationState = new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: nameof(CustomAuthenticationStateProvider))));

            return authenticationState;
        }

        public async Task SetCurrentUserAsync(User? currentUser)
        { 
            await _localStorageService.SetItem(LocalStorageKey, currentUser);

            NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
        }

        public Task GetCurrentUserAsync() => _localStorageService.GetItemAsync(LocalStorageKey);
    }
}

Don’t forget to register all authentication related services.

builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddSingleton();
builder.Services.AddSingleton(s => s.GetRequiredService());

In the App.razor add the CascadingAuthenticationState and AuthorizeRouteView components, so the AuthenticationState flows down to the components automagically.

@using Microsoft.AspNetCore.Components.Authorization



    
        
            
        
        
            Not found
            
                

Sorry, there's nothing at this address.

In the MainLayout, you can then use the <AuthorizeView> component, that allows to check, if a given user is authorized or not. If the User is not authorized, we are redirecting to the Login page using a <RedirectToLogin> component.

@using Microsoft.AspNetCore.Components
@using System.Runtime.InteropServices
@using RebacExperiments.Blazor.Components
@using RebacExperiments.Blazor.Components.RedirectToLogin
@namespace RebacExperiments.Blazor.Shared

Relationship-based Experiments with ASP.NET Core OData

    
        
    
    
        
    

The <RedirectToLogin> component simply uses the NavigationManager to navigate to the Login Page.

@inject NavigationManager Navigation

@code {
    protected override void OnInitialized()
    {
        var baseRelativePath = Navigation.ToBaseRelativePath(Navigation.Uri);

        if(string.IsNullOrWhiteSpace(baseRelativePath))
        {
            Navigation.NavigateTo($"Login", true);
        } else {
            Navigation.NavigateTo($"Login?returnUrl={Uri.EscapeDataString(baseRelativePath)}", true);
        }
    }
}

Now what happens, if the Web service returns a HTTP Status Code 401 (Unauthorized) and we still have the User in the Local Storage? Yes, it will be out of sync. So we need to update the AuthenticationState and clear the User information, if the service returns a HTTP Status Code 401.

This can be done by using a DelegatingHandler, that takes a dependency on our CustomAuthenticationStateProvider, and sets the current User to null. This should inform all subscribers, that we are now unauthorized to perform actions.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using RebacExperiments.Blazor.Shared.Logging;

namespace RebacExperiments.Blazor.Infrastructure
{
    public class UnauthorizedDelegatingHandler : DelegatingHandler
    {
        private readonly ILogger _logger;

        private readonly CustomAuthenticationStateProvider _customAuthenticationStateProvider;

        public UnauthorizedDelegatingHandler(ILogger logger, CustomAuthenticationStateProvider customAuthenticationStateProvider)
        {
            _logger = logger;
            _customAuthenticationStateProvider = customAuthenticationStateProvider;
        }

        protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            _logger.TraceMethodEntry();

            var response = await base.SendAsync(request, cancellationToken);

            if(response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
            {
                var currentUser = await _customAuthenticationStateProvider.GetCurrentUserAsync();

                if(currentUser != null)
                {
                    await _customAuthenticationStateProvider.SetCurrentUserAsync(null);
                }
            }

            return response;
        }
    }
}

You need to add the UnauthorizedDelegatingHandler to the HttpClient.

builder.Services
    .AddHttpClient(client => client.BaseAddress = new Uri("https://localhost:5000"))
    .AddHttpMessageHandler()
    .AddHttpMessageHandler();

Now let’s connect everything!

I want the Login Page to have its own layout and don’t want to use the MainLayout. So I am adding an <EmptyLayout> component.

@inherits LayoutComponentBase

@Body

This EmptyLayout is then used as the Layout for the Login Page, so I can style it to my needs. The example uses a <SimpleValidator> for validation, that has been developed in a previous article. You could easily replace it with a <DataAnnotationsValidator>, to use Blazors built-in validations.

@page "/Login"
@layout EmptyLayout

@using RebacExperiments.Shared.ApiSdk

@inject ApiClient ApiClient
@inject IStringLocalizer Loc
@inject NavigationManager NavigationManager
@inject CustomAuthenticationStateProvider AuthStateProvider

Login

Login @if(!string.IsNullOrWhiteSpace(ErrorMessage)) { }

Let’s take a look at the Login.razor.cs Code-Behind.

The Login#SignInUserAsync methods starts by logging the User in. The Server will return the HttpOnly Cookie, that’s going to be sent with every request to the API. To get the User information for populating the AuthenticationState the /Me endpoint is called. The User is the set in the AuthStateProvider and we navigate to our application.

// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.AspNetCore.Components;
using RebacExperiments.Shared.ApiSdk.Odata.SignInUser;
using System.ComponentModel.DataAnnotations;
using RebacExperiments.Blazor.Infrastructure;
using Microsoft.Extensions.Localization;

namespace RebacExperiments.Blazor.Pages
{
    public partial class Login
    {
        /// 
        /// Data Model for binding to the Form.
        /// 
        private sealed class InputModel
        {
            /// 
            /// Gets or sets the Email.
            /// 
            [Required]
            [EmailAddress]
            public required string Email { get; set; }

            /// 
            /// Gets or sets the Password.
            /// 
            [Required]
            [DataType(DataType.Password)]
            public required string Password { get; set; }

            /// 
            /// Gets or sets the RememberMe Flag.
            /// 
            [Required]
            public bool RememberMe { get; set; } = false;
        }

        // Default Values.
        private static class Defaults
        {
            public static class Philipp
            {
                public const string Email = "[email protected]";
                public const string Password = "5!F25GbKwU3P";
                public const bool RememberMe = true;
            }

            public static class MaxMustermann
            {
                public const string Email = "[email protected]";
                public const string Password = "5!F25GbKwU3P";
                public const bool RememberMe = true;
            }
        }


        /// 
        /// If a Return URL is given, we will navigate there after login.
        /// 
        [SupplyParameterFromQuery(Name = "returnUrl")]
        private string? ReturnUrl { get; set; }

        /// 
        /// The Model the Form is going to bind to.
        /// 
        [SupplyParameterFromForm]
        private InputModel Input { get; set; } = new()
        {
            Email = Defaults.Philipp.Email,
            Password = Defaults.Philipp.Password,
            RememberMe = Defaults.Philipp.RememberMe
        };

        /// 
        /// Error Message.
        /// 
        private string? ErrorMessage;

        /// 
        /// Signs in the User to the Service using Cookie Authentication.
        /// 
        /// 
        public async Task SignInUserAsync()
        {
            ErrorMessage = null;

            try
            {
                await ApiClient.Odata.SignInUser.PostAsync(new SignInUserPostRequestBody
                {
                    Username = Input.Email,
                    Password = Input.Password,
                    RememberMe = true
                });

                // Now refresh the Authentication State:
                var me = await ApiClient.Odata.Me.GetAsync();

                await AuthStateProvider.SetCurrentUserAsync(me);

                var navigationUrl = GetNavigationUrl();

                NavigationManager.NavigateTo(navigationUrl);
            }
            catch
            {
                ErrorMessage = Loc["Login_Failed"];
            }
        }

        private string GetNavigationUrl()
        {
            if(string.IsNullOrWhiteSpace(ReturnUrl))
            {
                return "/";
            }

            return ReturnUrl;
        }

        /// 
        /// Validates an <see cref="InputModel"/>.
        /// 
        /// InputModel to validate
        /// The list of validation errors for the EditContext model fields
        private IEnumerable ValidateInputModel(InputModel model)
        {
            if(string.IsNullOrWhiteSpace(model.Email))
            {
                yield return new ValidationError
                {
                    PropertyName = nameof(model.Email),
                    ErrorMessage = Loc.GetString("Validation_IsRequired", nameof(model.Email))
                };
            }

            if(string.IsNullOrWhiteSpace(model.Password))
            {
                yield return new ValidationError
                {
                    PropertyName = nameof(model.Password),
                    ErrorMessage = Loc.GetString("Validation_IsRequired", nameof(model.Password))
                };
            }
        }
    }
}

In the Login.razor.css we add a bit of styling.

@keyframes fade {
    from {
        opacity: 0;
    }

    to {
        opacity: 1;
    }
}

.container {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    animation: fade 0.2s ease-in-out forwards;
}

h1 {
    font-size: 35px;
    font-weight: 100;
    text-align: center;
}

Conclusion

And that’s it! You will now be able to use Cookie Authentication in your Blazor Application.

References
https://www.bytefish.de/blog/blazor_wasm_cookie_authentication.html

Custom NavLink to Support Complex URL Matching in ASP.NET Blazor

To make the “Home” NavLink selected when navigating to http://localhost:3002/ or http://localhost:3002/Monitoring/, you can adjust the Match attribute of the NavLink to use a custom match condition. Blazor does not support complex URL matching directly out of the box, but you can achieve this by creating a custom CustomNavLink component.

CustomNavLink.razor

@inject NavigationManager Navigation
@implements IDisposable

@if (IsActive)
{
    <NavLink class="nav-link active" href="" Match="NavLinkMatch.All">
        @ChildContent
    </NavLink>
}
else
{
    <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
        @ChildContent
    </NavLink>
}

@code {
    [Parameter] public RenderFragment ChildContent { get; set; } = default!;

    private bool IsActive { get; set; }

    protected override void OnInitialized()
    {
        Navigation.LocationChanged += OnLocationChanged;
    }
    
    private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
    {
        // Handle the URL change here
        IsActive = MatchUrl();
        StateHasChanged(); // Update the UI
    }
    
    private bool MatchUrl()
    {
        var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).PathAndQuery;

        if (uri.Equals("/"))
        {
            return true;
        }

        if (uri.StartsWith("/Monitoring", StringComparison.OrdinalIgnoreCase))
        {
            return true;
        }
        
        return false;
    }

    [Inject] private NavigationManager NavigationManager { get; set; } = default!;
    public void Dispose()
    {
        Navigation.LocationChanged -= OnLocationChanged;
    }

}
<CustomNavLink>
    <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</CustomNavLink>

 

Handle an Unknown Number of Route Parameters in ASP.NET Blazor

To handle an unknown number of route parameters in ASP.NET Blazor, you can define a single route with a wildcard parameter to capture the entire path.

Here’s how you can achieve this in Blazor:

  1. Define the Route: Use a route parameter in the @page directive. The wildcard parameter is specified using an asterisk (*). This allows you to capture the entire path as a single string.
  2. Process the Parameters: Once you have the full path, you can split it into individual parameters and process them as needed.
@page "/Monitoring/{**Parameters}"
@inject NavigationManager Navigation


<h3>Monitoring</h3>

<ul>
    @foreach (var param in RouteParameters)
    {
    <li>@param</li>
    }
</ul>
[Parameter]
public string Parameters { get; set; }

private List<string> RouteParameters { get; set; } = new();

protected override void OnParametersSet()
{
    var uri = new Uri(Navigation.Uri);
    var path = uri.AbsolutePath;

    // Remove the initial part of the path
    var trimmedPath = path.Substring("/Monitoring/".Length);

    // Split the remaining part of the path by '/'
    RouteParameters = trimmedPath.Split('/', StringSplitOptions.RemoveEmptyEntries).ToList();
}

 

ASP.NET Core Blazor file uploads

ASP.NET Core’s Blazor provides a modern, component-based architecture for developing interactive web applications. One of its powerful features is handling file uploads with minimal fuss. This blog post will walk you through the process of setting up file uploads in your Blazor application, ensuring your journey in creating a feature-rich, user-friendly app remains smooth.

Introduction to File Uploads in Blazor

Blazor makes it a breeze to accept file uploads, providing an intuitive approach to handle user interaction. At the heart of this feature is the <InputFile> component, an integral part of Blazor’s component library.

The syntax for utilizing this component is straightforward. You can use the OnChange attribute to specify an event callback, which will be triggered when the user selects a file. To allow multiple file selections, simply add the multiple attribute as shown below:

<InputFile OnChange="@LoadFiles" multiple />

Next, let’s dive into the LoadFiles method, which is invoked when the user selects a file (or files) to upload.

@code {
    private void LoadFiles(InputFileChangeEventArgs e)
    {
        ...
    }
}

InputFileChangeEventArgs gives you access to the selected files. You can then manipulate the file data according to your needs.

Handling File Streams

One common requirement during file uploads is to write the uploaded file data to disk. The Blazor framework provides a convenient method, OpenReadStream(), that returns a Stream. This stream can then be copied to a FileStream to write the data to disk.

Here is a sample code snippet for this:

await using FileStream fs = new(path, FileMode.Create);
await browserFile.OpenReadStream().CopyToAsync(fs);

In this snippet, path is the destination path where the file will be stored, and browserFile is an instance of IBrowserFile representing the uploaded file.

Integration with Azure Blob Storage

If you are using Azure Blob Storage for storing files, Blazor’s integration with Azure makes it easy to upload the file directly. Here’s how you can do this:

await blobContainerClient.UploadBlobAsync(
    trustedFileName, browserFile.OpenReadStream());

blobContainerClient is an instance of BlobContainerClient representing the Azure Blob Storage container, and trustedFileName is the name under which the file will be stored.

Limiting File Sizes

Sometimes, you may want to limit the size of the files that the users can upload. Blazor provides a neat way to do this. You can use the maxAllowedSize parameter in the OpenReadStream method:

// accept a file upto 307200 bytes (300kb) of size
await myFile.OpenReadStream(maxAllowedSize: 1024 * 300).ReadAsync(buffers);

This code will allow only files of size up to 300 KB to be uploaded. If a user tries to upload a larger file, an exception will be thrown.

Wrapping Up

The ability to handle file uploads effectively and efficiently is a key aspect of many web applications. ASP.NET Core Blazor simplifies this process with its robust, developer-friendly components and methods, making file uploads not only functional but also a cinch to implement. Whether you are writing files to disk or using Azure Blob Storage for handling your uploads, Blazor has got you covered. Happy coding!

Check Authorization Rules Programatically in ASP.NET Blazor

_Imports.razor

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

Pages/ProceduralLogic.razor:

@page "/procedural-logic"
@inject IAuthorizationService AuthorizationService

<h1>Procedural Logic Example</h1>

<button @onclick="@DoSomething">Do something important</button>

@code {
    [CascadingParameter]
    private Task<AuthenticationState>? authenticationState { get; set; }

    private async Task DoSomething()
    {
        if (authenticationState is not null)
        {
            var authState = await authenticationState;
            var user = authState?.User;

            if (user is not null)
            {
                if (user.Identity is not null && user.Identity.IsAuthenticated)
                {
                    // ...
                }

                if (user.IsInRole("Admin"))
                {
                    // ...
                }

                if ((await AuthorizationService.AuthorizeAsync(user, "content-editor"))
                    .Succeeded)
                {
                    // ...
                }
            }
        }
    }
}

References
https://learn.microsoft.com/en-us/aspnet/core/blazor/security/?view=aspnetcore-6.0#procedural-logic

Create Admin Account on ASP.NET Blazor Startup

public class StartupWorker: BackgroundService
{
    private readonly IServiceProvider _serviceProvider;
    private static readonly string[] Roles = { "Admin", "Manager", "Member" };
    
    public StartupWorker(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await CreateRoles();
        await CreateAdmin();
    }

    private async Task CreateRoles()
    {
        using var serviceScope = _serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope();
        var roleManager = serviceScope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
        foreach (var role in Roles)
        {
            if (!await roleManager.RoleExistsAsync(role))
            {
                await roleManager.CreateAsync(new IdentityRole(role));
            }
        }
    }

    private async Task CreateAdmin()
    {
        using var serviceScope = _serviceProvider.GetRequiredService<IServiceScopeFactory>().CreateScope();
        var userManager = serviceScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
        var user = await userManager.FindByNameAsync("admin");

        if (user == null)
        {
            var identity = new ApplicationUser("admin") { FirstName = "Admin", LastName = "" };
            var password = "12345";
            await userManager.CreateAsync(identity, password);
            await userManager.AddToRoleAsync(identity, "Admin");
        }
    }
}

References
https://learn.microsoft.com/en-us/aspnet/core/security/authorization/secure-data?view=aspnetcore-7.0

Add Role services to Identity in ASP.NET Blazor

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(
    options => options.SignIn.RequireConfirmedAccount = true)
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

References
https://learn.microsoft.com/en-us/aspnet/core/security/authorization/secure-data?view=aspnetcore-7.0
https://stackoverflow.com/questions/52522248/store-does-not-implement-iuserrolestoretuser-asp-net-core-identity

Detect Online Users using Circuit Handler in ASP.NET Blazor Server

public class UserCircuitHandler : CircuitHandler
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public UserCircuitHandler(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public override async Task OnConnectionUpAsync(Circuit circuit,
        CancellationToken cancellationToken)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        var userAgent = httpContext.Request.Headers["User-Agent"];
        string uaString = userAgent.ToString();
        var uaParser = Parser.GetDefault();
        ClientInfo c = uaParser.Parse(uaString);
        Console.WriteLine(httpContext.User.Identity.Name);
    }

    public override async Task OnConnectionDownAsync(Circuit circuit,
        CancellationToken cancellationToken)
    {
        var httpContext = _httpContextAccessor.HttpContext;
        var userAgent = httpContext.Request.Headers["User-Agent"];
        string uaString = userAgent.ToString();
        var uaParser = Parser.GetDefault();
        ClientInfo c = uaParser.Parse(uaString);
        Console.WriteLine(httpContext.User.Identity.Name);
    }
}

References
https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/signalr?view=aspnetcore-7.0#blazor-server-circuit-handler
https://learn.microsoft.com/en-us/aspnet/core/blazor/security/server/additional-scenarios?view=aspnetcore-7.0#circuit-handler-to-capture-users-for-custom-services

Apply Authorization to All Pages in Blazor

Program.cs

builder.Services.AddAuthorization(options =>
{
    options.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser()
        .Build();
});

builder.Services.ConfigureApplicationCookie(options =>
{
    options.ExpireTimeSpan=TimeSpan.FromDays(14);
    options.SlidingExpiration = true;
    options.Cookie.Name = "BI";
    options.Cookie.HttpOnly = true;
    options.LoginPath = "/Login";
    options.LogoutPath="/Logout";
});

To apply authorization to all pages in Blazor, you have to add:

@attribute [Microsoft.AspNetCore.Authorization.Authorize]

…to your _Imports.razor file.

@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]

…on pages that don’t require authorization.

References
https://stackoverflow.com/questions/60840986/blazor-redirect-to-login-if-user-is-not-authenticated
https://stackoverflow.com/questions/71434131/blazor-allow-anonymous-for-razor-page
https://stackoverflow.com/questions/50633896/asp-net-core-identity-change-login-url
https://learn.microsoft.com/en-us/aspnet/core/security/authorization/secure-data?view=aspnetcore-7.0

Automatically refresh the page when reconnection fails in ASP.NET Blazor Server

Pages/_Host.cshtml:

<body>
    ...

    <div id="reconnect-modal" style="display: none;"></div>
    <script src="_framework/blazor.server.js" autostart="false"></script>
    <script src="boot.js"></script>
</body>

wwwroot/boot.js:

(() => {
  const maximumRetryCount = 3;
  const retryIntervalMilliseconds = 5000;
  const reconnectModal = document.getElementById('reconnect-modal');

  const startReconnectionProcess = () => {
    reconnectModal.style.display = 'block';

    let isCanceled = false;

    (async () => {
      for (let i = 0; i < maximumRetryCount; i++) {
        reconnectModal.innerText = `Attempting to reconnect: ${i + 1} of ${maximumRetryCount}`;

        await new Promise(resolve => setTimeout(resolve, retryIntervalMilliseconds));

        if (isCanceled) {
          return;
        }

        try {
          const result = await Blazor.reconnect();
          if (!result) {
            // The server was reached, but the connection was rejected; reload the page.
            location.reload();
            return;
          }

          // Successfully reconnected to the server.
          return;
        } catch {
          // Didn't reach the server; try again.
        }
      }

      // Retried too many times; reload the page.
      location.reload();
    })();

    return {
      cancel: () => {
        isCanceled = true;
        reconnectModal.style.display = 'none';
      },
    };
  };

  let currentReconnectionProcess = null;

  Blazor.start({
    reconnectionHandler: {
      onConnectionDown: () => currentReconnectionProcess ??= startReconnectionProcess(),
      onConnectionUp: () => {
        currentReconnectionProcess?.cancel();
        currentReconnectionProcess = null;
      },
    },
  });
})();

References
https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/signalr?view=aspnetcore-7.0#automatically-refresh-the-page-when-reconnection-fails-blazor-server