Click here to Skip to main content
15,894,646 members
Articles / Programming Languages / C#

Blazor WASM Hosted App with Cookie-based Authentication and Microsoft Identity

Rate me:
Please Sign up or sign in to vote.
5.00/5 (6 votes)
24 Aug 2023CPOL8 min read 15.2K   21   4
Build Blazor WASM hosted app with Authentication and Authorization
This article is for those interested in building a Blazor WASM hosted app that supports authentication and would like to use Microsoft Identity instead of using IdentityServer or any other OpenID compatible service.

Introduction

In this article, we will see how to build a Blazor WASM hosted application, which requires Authentication and Authorization using the Microsoft Identity framework instead of IdentityServer which comes with the default Blazor templates.

We will focus on Cookie-based authentication which can be sufficient for most WASM hosted applications that are hosted together with the backend and do not require single sign-on.

Another important factor is the lack of prerendering support in the default authentication scheme in Blazor WASM applications (ASP.NET Core Blazor WebAssembly additional security scenarios | Microsoft Learn).

Considerations

Since Blazor WASM are considered SPA projects, the communication with the server is done via API calls. In most cases, API calls to endpoints requiring authentication is done with the use of tokens. In our case, we'll have to override this behavior and allow API calls to authenticate with Cookies instead and at the same time, ensure that our backend supports it.

Login is handled by redirecting to the default ASP.NET Core Identity UI, which is hosted in our backend. Since Blazor WASM and ASP.NET Core backend are hosted on the same url, Cookies can be shared between the backend and our WASM frontend.

Creating Our Project in Visual Studio

Create your Project in Visual Studio 2022. Make sure you select the Blazor WebAssembly templates and the authentication is None... we'll add the authentication later on manually.

Image 1

Install Microsoft Identity UI

Using Package manager console, install the Identity UI:

C#
Install-Package Microsoft.AspNetCore.Identity.UI 

We'll use MS SQL to store our users for this example. Feel free to use any other provider or even implement the necessary interfaces to customize Identity according to your infrastructure. You can find more information on how to do so here.

C#
Install-Package Microsoft.AspNetCore.Identity.EntityFrameworkCore
Install-Package Microsoft.EntityFrameworkCore.SqlServer
Install-Package Microsoft.EntityFrameworkCore.Tools

Adding Microsoft Identity Default UI to Our Server Project

Create your Identity DbContext and register it in ASP.NET Core default dependency manager.

C#
public class ApplicationDbContext : IdentityDbContext<IdentityUser>
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options)
    {
    }
}

Create your DB connection appsettings.json and set this when registering the DBContext:

C#
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") 
?? throw new InvalidOperationException
   ("Connection string 'DefaultConnection' not found.");

builder.Services.AddDbContext<ApplicationDbContext>(options => 
options.UseSqlServer(connectionString));

Register the default ASP.NET Identity stores:

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

Since we'll be using Cookie-based authentication, and the Cookie will be passed to our HttpClient in our Blazor WASM project, we have to make sure that unauthenticated responses are returned with status code 401 instead of 302 (redirect) and pass the redirect location to the Location header which can be used by the Blazor client to redirect to the Login url.

C#
builder.Services.ConfigureApplicationCookie(options =>
{
    options.Events.OnRedirectToLogin = context =>
    {
        context.Response.Headers["Location"] = context.RedirectUri;
        context.Response.StatusCode = 401;
        return Task.CompletedTask;
    };
});

Create the partial login page under Pages/Shared folder of your Server project.

Image 2

Instruct your backend to use authorization after routing:

C#
 app.UseRouting();

 app.UseAuthorization();

At this point, our backend is configured to use the Microsoft Identity framework for authorization, which will produce a Cookie whenever a user logs in to the server backend with the build-in UI.

You can verify that the Identity UI is available by running your application and manually visiting the url: /Identity/Account/Login.

Image 3

Since the authentication is happening on our backend (Server app), we need a means of sharing the Claims and basic user information with our Client WASM app.

To handle this, we need an API endpoint, which will return the user profile to our client app:

C#
[Route("api/[controller]")]
[ApiController]
public class AuthController : ControllerBase
{
    private readonly ApplicationDbContext _applicationDbContext;
    public AuthController(ApplicationDbContext applicationDbContext)
    {
        _applicationDbContext = applicationDbContext;
    }

    [Authorize]
    [HttpGet]
    [Route("user-profile")]
    public async Task<IActionResult> UserProfileAsync()
    {
        string userId = HttpContext.User.Claims.Where
        (_ => _.Type == ClaimTypes.NameIdentifier).Select(_ => _.Value).First();

        var userProfile = await _applicationDbContext.Users.Where(_ => _.Id == userId)
        .Select(_ => new UserProfileDto
        {
            UserId = _.Id,
            Email = _.Email,
            Name = _.UserName,
        }).FirstOrDefaultAsync();

        return Ok(userProfile);
    }
}

You should add the UserProfile DTO in your default shared project as this should be accessible from both the Server and Client apps.

C#
public class UserProfileDto
{
    public string UserId { get; set; }
    public string? Email { get; set; }
    public string? Name { get; set; }
}

Passing the Authentication Cookie to the HttpClient in our Blazor WASM Client App and Setting the Authentication State

Since our server app now requires authentication and the authentication is set to require an authentication cookie, you have to pass the cookie to our HttpClient, which performs the API requests to the server.

Before we start, make sure you install the Authorization and Http extension in your Client WASM project.

C#
Install-Package Microsoft.AspNetCore.Components.WebAssembly.Authentication
Install-Package Microsoft.Extensions.Http 

To inject the Cookie in our HttpClient, we need a Handler which will instruct the HttpClient to set the credentials from the cookie stored in our browser:

C#
public class CookieHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> 
    SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include);

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

We then need to register the HttpClient and CookieHandler in our container:

C#
builder.Services.AddScoped(sp => new HttpClient 
        { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

builder.Services.AddScoped<CookieHandler>();

builder.Services.AddHttpClient("BlazorWasmAppCookieAuth.ServerAPI", 
    client => client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress))
   .AddHttpMessageHandler<CookieHandler>();

Finally, we need to provide our implementation of the AuthenticationStateProvider which now sets the ClaimsPrincipal according to the user profile retrieved from the server.

C#
public class CustomAuthStateProvider : AuthenticationStateProvider
{
    private ClaimsPrincipal claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
    private readonly IHttpClientFactory _httpClientFactory;
    private UserProfileDto? _userProfileDto;
    public CustomAuthStateProvider(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public override async Task<AuthenticationState> GetAuthenticationStateAsync()
    {
        if(_userProfileDto == null)
        {
            var client = _httpClientFactory.CreateClient
                         ("BlazorWasmAppCookieAuth.ServerAPI");
            var response = await client.GetAsync("/api/Auth/user-profile");
            if (response.IsSuccessStatusCode)
            {
                _userProfileDto = 
                await response.Content.ReadFromJsonAsync<UserProfileDto>();
                var identity = new ClaimsIdentity(new[]{
                        new Claim(ClaimTypes.Email, _userProfileDto?.Email ?? ""),
                        new Claim(ClaimTypes.Name, _userProfileDto ?.Name ?? ""),
                        new Claim("UserId", _userProfileDto?.ToString() ?? "")
                }, "AuthCookie");

                claimsPrincipal = new ClaimsPrincipal(identity);
                //NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
            }
        }
        return new AuthenticationState(claimsPrincipal);
    }
    
    public void SignOut()
    {
        _userProfileDto = null;
        claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity());
        NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
    }
}

We override the GetAuthenticationStateAsync method, in which we call the Server API published before to read the user information after the users get authenticated in the server app. In case the API client returns a 401 message, we'll set an empty ClaimsPrincipal which indicates that there is no user currently logged in.

You can choose to implement the SignOut method, which can then be used to logout the user from the client app without the need to redirect to the server IdentityUI logout page. To achieve this, we'll have to implement a Logout endpoint in your Auth controller to signout the user from the Server and destroy the authentication cookie.

The CustomAuthStateProvider should be registered in your client dependency container together with the Core authentication classes.

C#
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();

builder.Services.AddAuthorizationCore();

Provide the CascadingAuthenticationState in your App.razor component:

Image 4

And the RedirectToLogin.razor component:

Image 5

Notice in this case, we are overriding the OnAfterRender method instead of OnInitialized, which will support prerendering.

Enabling Prerendering

One important limitation of Blazor WASM hosted applications is the fact that they suffer from heavy initial loading times. This is due to the fact that the browser will first have to download the binaries of the application, which then will be used to render the page. Since the rendering logic relies in the C# code and ultimately the wasm binary, a significant number of binaries should be downloaded before the application can start rendering the html. The binaries can, of course, get cached at the browser for future loads, but this depends on the client, and we also have to consider the cases where we publish an update to our app. In such cases, the binaries should be downloaded again by the client. Thought this can happen under the scenes for cashed applications, which will then require the client to reload the app.

Thankfully, this problem can be solved with prerendering! Prerendering ensures that the client will be served immediately with the page html, while at the same time, the browser will start downloading the wasm binaries on the background, which will be used for the following requests. This in essence means that the C# application logic is executed at the server instead of the client, and the server responds with final html. Application actions and interactivity thought is still handled client-side by the wasm binaries. Prerendering can also help with SEO, since the initial html response can be used by search engines to calculate the page rank.

To enable prerendering, we have to ensure that our client wasm code can also be executed at the server, and hence we need to include in our server dependency container all client client services or provide equivalent by registering the corresponding server implementation or the service.

Replacing the Application Entry Point

To enable prerendering, we first have to move our entry route from the client to the server.

To do so, we have to change the fall pack page from index.html, which is available in our client wasm project, to a new page that can be served by the server. Simply change the code below as shown below at the server program.cs class.

C#
//app.MapFallbackToFile("index.html");
app.MapFallbackToPage("/_Host");

Creating the _Host page

For the _Host page, simple add a new Razor page under the pages on your server project and past the html code from the index.html file. Microsoft suggests taking the default template from an empty server project, I prefer copying the contents of my index.html page to make sure I'm not skipping any client js libraries or css I added to my project.

You can then add the page route, "/" to indicate this is the default route and a using statement to your client namespace. This will enable you to replace the app div tag, which is the placeholder in where the application is rendered, with a component rendering the client app. The component render-mode should be set to "WebAssemblyPrerendered", which instructs our server app to prerender this component and respond with the html.

The final code will look like the below:

HTML
@page "/"
@using BlazorWasmAppCookieAuth.Client
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, 
     initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>BlazorWasmAppCookieAuth</title>
    <base href="/" />
    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/app.css" rel="stylesheet" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <link href="BlazorWasmAppCookieAuth.Client.styles.css" rel="stylesheet" />
    <link href="manifest.json" rel="manifest" />
    <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
    <link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
</head>

<body>
    <component type="typeof(BlazorWasmAppCookieAuth.Client.App)" 
     render-mode="WebAssemblyPrerendered" />

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>
    <script>navigator.serviceWorker.register('service-worker.js');</script>
</body>

</html>

You should then comment out the two lines shown below from your client Program.cs class, which define the id of the div in which the client will render the app.

C#
var builder = WebAssemblyHostBuilder.CreateDefault(args);
//builder.RootComponents.Add<App>("#app");
//builder.RootComponents.Add<HeadOutlet>("head::after");

Registering Client Specific Services at the Server

Finally, we need to register our client services to the server project, since those services will be used by the server while prerendering to generate the html code to be send to the browser.

The first class we need to register is the IHttpClientFactory, which is used to create the HttpClient used to perform the API calls. Now since this HttpClient will perform API calls to the same application, instead of the HttpClient, we can register a different service class, which will load the data directly from our DbContext instead of performing API calls for the server, and have a different implementation of the client to get the data over APIs. For matters of simplicity, I'm registering the HttpClient on the server, but let's agree this is not optimal.

Then we register our Custom authentication provider, which handles the Cookie authentication and redirect to the login page.

C#
builder.Services.AddScoped(sp => 
        sp.GetRequiredService<IHttpClientFactory>().CreateClient
        ("BlazorWasmAppCookieAuth.ServerAPI"));
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddScoped<CookieHandler>();

builder.Services.AddHttpClient("BlazorWasmAppCookieAuth.ServerAPI", 
        client => client.BaseAddress = new Uri("https://localhost:7182"))
   .AddHttpMessageHandler<CookieHandler>();

Final Words

This solution helped me enhance my Blazor wasm applications experience dramatically. I trust things are moving in the right direction with Blazor and I can't wait for Streaming Rendering with SSR on .NET 8.0 and other features announced around Blazor.

Complete Solution

The complete solution with prerendering enabled can be found here.

History

  • 23rd June, 2023: Initial version
  • 20th July, 2023: Prerendering update

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Team Leader RiskMatrix
Cyprus Cyprus
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
QuestionCookie in HttpClient Pin
Oliver Weichhold5-Oct-23 6:29
Oliver Weichhold5-Oct-23 6:29 
AnswerRe: Cookie in HttpClient Pin
Michael Kanios5-Oct-23 6:51
professionalMichael Kanios5-Oct-23 6:51 
GeneralRe: Cookie in HttpClient Pin
Oliver Weichhold6-Oct-23 4:56
Oliver Weichhold6-Oct-23 4:56 
Questioncode kha hay Pin
giaminh771921-Jul-23 16:34
giaminh771921-Jul-23 16:34 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.