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.
Install Microsoft Identity UI
Using Package manager console, install the Identity UI
:
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.
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.
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
:
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:
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.
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.
Instruct your backend to use authorization after routing:
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.
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:
[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.
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.
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:
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:
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.
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);
}
}
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.
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthStateProvider>();
builder.Services.AddAuthorizationCore();
Provide the CascadingAuthenticationState
in your App.razor
component:
And the RedirectToLogin.razor
component:
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.
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:
@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.
var builder = WebAssemblyHostBuilder.CreateDefault(args);
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.
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