Search code examples
asp.net-corejwtblazorblazor-server-side.net-8.0

.NET 8 Blazor web app with Web API using JWT authentication


I have two .NET 8 projects, an ASP.NET Core 8 Web API and Blazor web app with interactive render mode set to server.

The Web API handles the authentication and provides a JWT token. The registration and login for getting a token works fine.

The issue arises when I try to use <AuthorizeView>. I added it to the Home.razor component as a basic test but I was then met with an error:

JavaScript interop calls cannot be issued at this time

I have not been able to progress beyond this issue.

Program.cs:

using AuthDemo.Blazor.Server.UI.Components;
using AuthDemo.Blazor.Server.UI.Infrastructure.Services.HttpClients;
using Blazored.LocalStorage;
using AuthDemo.Blazor.Server.UI.Infrastructure.Services.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
using AuthDemo.Blazor.Server.UI.Infrastructure.Providers.Authentication;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

builder.Services.AddCascadingAuthenticationState();

builder.Services.AddBlazoredLocalStorage();

builder.Services.AddHttpClient<IAuthDemoApiClient, AuthDemoApiClient>(cl => cl.BaseAddress = new Uri("https://localhost:7287"));
builder.Services.AddScoped<IAuthenticationService, AuthenticationService>();
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(p => p.GetRequiredService<CustomAuthenticationStateProvider>());

builder.Services.AddRouting(options =>
{
    options.LowercaseUrls = true;
});

var app = builder.Build();

// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
    app.UseHsts();
}

app.UseHttpsRedirection();

app.UseStaticFiles();
app.UseStatusCodePagesWithReExecute("/StatusCode/{0}");
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

app.Run();

App.razor:

<!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="bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="app.css" />
    <link rel="stylesheet" href="AuthDemo.Blazor.Server.UI.styles.css" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <HeadOutlet @rendermode="InteractiveServer" />
</head>

<body>
    <Routes @rendermode="InteractiveServer" />
    <script src="_framework/blazor.web.js"></script>
</body>

</html>

AuthenticationService.cs:

using AuthDemo.Blazor.Server.UI.Infrastructure.Services.HttpClients;
using Microsoft.AspNetCore.Identity.Data;

namespace AuthDemo.Blazor.Server.UI.Infrastructure.Services.Authentication
{
    public class AuthenticationService : IAuthenticationService
    {
        private readonly IAuthDemoApiClient _AuthDemoApiClient;

        public AuthenticationService(IAuthDemoApiClient AuthDemoApiClient)
        {
            _AuthDemoApiClient = AuthDemoApiClient;
        }

        public async Task<AuthenticationResponseDto?> LoginAsync(LoginDto loginDto)
        {
            var result = await _AuthDemoApiClient.LoginAsync(loginDto);
            return result;
        }
    }
}

CustomAuthenticationStateProvider.cs:

using Blazored.LocalStorage;
using Microsoft.AspNetCore.Components.Authorization;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;

namespace AuthDemo.Blazor.Server.UI.Infrastructure.Providers.Authentication
{
    public class CustomAuthenticationStateProvider : AuthenticationStateProvider
    {

        private readonly ILocalStorageService _localStorageService;
        private ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
        private readonly JwtSecurityTokenHandler _jwtSecurityTokenHandler;

        public CustomAuthenticationStateProvider(ILocalStorageService localStorageService)
        {
            _localStorageService = localStorageService;
            _jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
        }

        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            var token = await _localStorageService.GetItemAsync<string>("accessToken");

            if (string.IsNullOrEmpty(token))
            {
                return new AuthenticationState(_anonymous);
            }

            var tokenContent = _jwtSecurityTokenHandler.ReadJwtToken(token);
            var claims = tokenContent.Claims;
            var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt"));

            return await Task.FromResult(new AuthenticationState(user));
        }

        public void AuthenticateUser(string token)
        {
            var tokenContent = _jwtSecurityTokenHandler.ReadJwtToken(token);
            var claims = tokenContent.Claims;
            var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt"));
            var state = new AuthenticationState(user);
            NotifyAuthenticationStateChanged(Task.FromResult(state));
        }
    }
}

Login.razor:

@page "/auth/login"

@inject IAuthenticationService _authenticationService
@inject AuthenticationStateProvider _authenticationStateProvider
@inject IAuthDemoApiClient _httpAuthDemoApiClient;
@inject ILocalStorageService _localStorageService;
@inject NavigationManager _navigationManager;

<h3>Login</h3>

@if (!string.IsNullOrEmpty(exMessage))
{
    <div class="alert alert-danger">
        <p>@exMessage</p>
    </div>
}

<div class="card-body">
    <EditForm Model="LoginDto" OnValidSubmit="HandleLogin">
        <DataAnnotationsValidator />
        <ValidationSummary />

        <div class="form-group">
            <label for="EmailAddress">Email Address</label>
            <InputText class="form-control" @bind-Value="LoginDto.EmailAddress" />
            <ValidationMessage For="@(() => LoginDto.EmailAddress)" />
        </div>
        <br />

        <div class="form-group">
            <label for="Password">Password</label>
            <InputText type="password" class="form-control" @bind-Value="LoginDto.Password" />
            <ValidationMessage For="@(() => LoginDto.Password)" />
        </div>

        <button type="submit" class="btn btn-primary btn-block">Login</button>
    </EditForm>
</div>

@code {
    LoginDto LoginDto = new LoginDto();
    string exMessage = string.Empty;

    private async Task HandleLogin()
    {
        try
        {
            var authResponse = await _authenticationService.LoginAsync(LoginDto);
            if (authResponse != null)
            {
                await _localStorageService.SetItemAsync("token", authResponse.Token);
                ((CustomAuthenticationStateProvider)_authenticationStateProvider).AuthenticateUser(authResponse.Token!);
                _navigationManager.NavigateTo("/");
            }
        }
        catch (ApiException ex)
        {
            exMessage = ex.Response;
        }
        catch (Exception ex)
        {
            exMessage = ex.Message;
        }
    }
}

The above works, I can login and I get back a token.

The issue occurs when I add the following:

Home.razor:

@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<AuthorizeView>
    <Authorized>
        <h1>logged in</h1>
    </Authorized>
    <NotAuthorized>
        <h1>not logged in</h1>
    </NotAuthorized>
</AuthorizeView>

When I run the project I get the following error:

enter image description here

I get the same issue if I add <AuthorizeView> to Routes.razor.

I am trying understand what the correct approach is here ...


Solution

  • <Routes @rendermode="InteractiveServer" /> is actually "ServerSignalR" + "SSR prerender" mode. But in the prerender, c# codes are executed in the Server, there's no way it can access browser localstorage. The _localStorageService.GetItemAsync only works in "ServerSignalR" mode.
    So the easiest way to makes these code work is to disable prerender in the App.razor like following:

    <HeadOutlet @rendermode="new InteractiveServerRenderMode(false)" />
    ...
    <Routes @rendermode="new InteractiveServerRenderMode(false)" />
    

    Since you are using InteractiveServer, disable SSR will not have big impact on user experience. But when you are using WASM, disable SSR will let user have to wait until the blazor resources are downloaded. In that scenario, save the token in localstorage is not the option.