Add Json Web Token (JWT) Bearer Authentication to Blazor WebAssembly

This example deals with both the server- and client-side implementation.

Create a new Blazor WebAssembly application called "BlazorExample" in this example.

The next step is to add the right NuGet packages to the Server, Client and Shared projects.

Add the Microsoft.AspNetCore.Authentication.JwtBearer NuGet package to the ASP.NET Core server project.

Add the System.ComponentModel.Annotations NuGet package to the client (Blazor WebAssembly) project.

Add the System.ComponentModel.Annotations NuGet package to the C# shared project.

Here is the AuthModel.cs file located in the root of the shared project.

// AuthModel.cs
using System;
using System.ComponentModel.DataAnnotations;
using System.Text;

namespace BlazorExample.Shared
{
	public class LoginResult
	{
		public string message { get; set; }
		public string email { get; set; }
		public string jwtBearer { get; set; }
		public bool success { get; set; }
	}
	public class LoginModel
	{
		[Required(ErrorMessage = "Email is required.")]
		[EmailAddress(ErrorMessage = "Email address is not valid.")]
		public string email { get; set; } // email will be the username, too

		[Required(ErrorMessage = "Password is required.")]
		[DataType(DataType.Password)]
		public string password { get; set; }
	}
	public class RegModel : LoginModel
	{
		[Required(ErrorMessage = "Confirm password is required.")]
		[DataType(DataType.Password)]
		[Compare("password", ErrorMessage = "Password and confirm password do not match.")]
		public string confirmpwd { get; set; }
	}
}

Here is the UserDatabase.cs file located in the server project. It is a crude example of a database for storing users. It is file-system based.

// UserDatabase.cs
using Microsoft.AspNetCore.Hosting;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

namespace BlazorExample.Server
{
	public class User
	{
		public string Email { get; }
		public User(string email)
		{
			Email = email;
		}
	}
	public interface IUserDatabase
	{
		Task<User> AuthenticateUser(string email, string password);
		Task<User> AddUser(string email, string password);
	}
	public class UserDatabase : IUserDatabase
	{
		private readonly IWebHostEnvironment env;
		public UserDatabase(IWebHostEnvironment env) => this.env = env;
		private static string CreateHash(string password)
		{
			var salt = "997eff51db1544c7a3c2ddeb2053f052";
			var md5 = new HMACMD5(Encoding.UTF8.GetBytes(salt + password));
			byte[] data = md5.ComputeHash(Encoding.UTF8.GetBytes(password));
			password = string.Empty;
			foreach (var x in data)
				password += x.ToString("X2");
			return password;
		}
		public async Task<User> AuthenticateUser(string email, string password)
		{
			return await Task.Run(() =>
			{
				if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
					return null;
				var path = env.ContentRootPath + "\\Users";
				if (!System.IO.Directory.Exists(path))
					return null;
				path += '\\' + email;
				if (!System.IO.File.Exists(path))
					return null;
				if (System.IO.File.ReadAllText(path) != CreateHash(password))
					return null;
				return new User(email);
			});
		}
		public async Task<User> AddUser(string email, string password)
		{
			return await Task.Run(() =>
			{
				try
				{
					if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
						return null;
					var path = env.ContentRootPath + "\\Users"; // THIS WILL CREATE THE "USERS" FOLDER IN THE PROJECT'S FOLDER!!!
					if (!System.IO.Directory.Exists(path))
						System.IO.Directory.CreateDirectory(path);
					path += '\\' + email;
					if (System.IO.File.Exists(path))
						return null;
					System.IO.File.WriteAllText(path, CreateHash(password));
					return new User(email);
				}
				catch
				{
					return null;
				}
			});
		}
	}
}

Here is the Startup.cs of the server project.

// Startup.cs
using Microsoft.AspNetCore.Authentication.JwtBearer; // NOTE: THIS LINE OF CODE IS NEWLY ADDED
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens; // NOTE: THIS LINE OF CODE IS NEWLY ADDED

namespace BlazorExample.Server
{
	public class Startup
	{
		public Startup(IConfiguration configuration)
		{
			Configuration = configuration;
		}

		public IConfiguration Configuration { get; }

		// This method gets called by the runtime. Use this method to add services to the container.
		// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
		public void ConfigureServices(IServiceCollection services)
		{

			services.AddControllersWithViews();
			services.AddRazorPages();

			// NOTE: LOCAL AUTHENTICATION CODE IS BELOW THIS LINE BUT INSIDE THIS BLOCK
			services.AddTransient<IUserDatabase, UserDatabase>();  // NOTE: LOCAL AUTHENTICATION ADDED HERE; AddTransient() IS OK TO USE BECAUSE STATE IS SAVED TO THE DRIVE

			// NOTE: the following lines are newly added
			services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>
			{
				options.TokenValidationParameters = new TokenValidationParameters
				{
					ValidateAudience = true,
					ValidAudience = "domain.com",
					ValidateIssuer = true,
					ValidIssuer = "domain.com",
					ValidateLifetime = true,
					ValidateIssuerSigningKey = true,
					IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("THIS IS THE SECRET KEY")) // NOTE: THIS SHOULD BE A SECRET KEY NOT TO BE SHARED
				};
			});
		}

		// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
		public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
		{
			if (env.IsDevelopment())
			{
				app.UseDeveloperExceptionPage();
				app.UseWebAssemblyDebugging();
			}
			else
			{
				app.UseExceptionHandler("/Error");
			}

			app.UseBlazorFrameworkFiles();

			app.UseStaticFiles();

			app.UseAuthentication(); // NOTE: line is newly added

			app.UseRouting();

			app.UseAuthorization(); // NOTE: line is newly addded, notice placement between UseRouting() and UseEndpoints()

			app.UseEndpoints(endpoints =>
			{
				endpoints.MapRazorPages();
				endpoints.MapControllers();
				endpoints.MapFallbackToFile("index.html");
			});
		}
	}
}

Here is the authentication controller file located in the Controllers folder of the server project. It is named AuthController.cs.

// AuthController.cs
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using BlazorExample.Server;
using BlazorExample.Shared;

namespace BlazorExample.Controllers
{
	[ApiController]
	public class AuthController : ControllerBase
	{
		private string CreateJWT(User user)
		{
			var secretkey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes("THIS IS THE SECRET KEY")); // NOTE: SAME KEY AS USED IN Startup.cs FILE
			var credentials = new SigningCredentials(secretkey, SecurityAlgorithms.HmacSha256);

			var claims = new[] // NOTE: could also use List<Claim> here
			{
				new Claim(JwtRegisteredClaimNames.Sub, user.Email), // this would be the username
				new Claim(JwtRegisteredClaimNames.Email, user.Email),
				new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")) // this could a unique ID assigned to the user by a database
			};

			var token = new JwtSecurityToken(issuer: "domain.com", audience: "domain.com", claims: claims, expires: DateTime.Now.AddMinutes(60), signingCredentials: credentials);
			return new JwtSecurityTokenHandler().WriteToken(token);
		}

		private IUserDatabase userdb { get; }

		public AuthController(IUserDatabase userdb)
		{
			this.userdb = userdb;
		}

		[HttpPost]
		[Route("api/auth/register")]
		public async Task<LoginResult> Post([FromBody] RegModel reg)
		{
			if (reg.password != reg.confirmpwd)
				return new LoginResult { message = "Password and confirm password do not match.", success = false };
			User newuser = await userdb.AddUser(reg.email, reg.password);
			if (newuser != null)
				return new LoginResult { message = "Registration successful.", jwtBearer = CreateJWT(newuser), email = reg.email, success = true };
			return new LoginResult { message = "User/password not found.", success = false };
		}

		[HttpPost]
		[Route("api/auth/login")]
		public async Task<LoginResult> Post([FromBody] LoginModel log)
		{
			User user = await userdb.AuthenticateUser(log.email, log.password);
			if (user != null)
				return new LoginResult { message = "Login successful.", jwtBearer = CreateJWT(user), email = log.email, success = true };
			return new LoginResult { message = "User/password not found.", success = false };
		}
	}
}

This is WeatherForecastController.cs located in the Controllers folder of the server project. Only three lines of code are added/modified.

// WeatherForecastController.cs
using BlazorExample.Shared;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Authorization; // NOTE: THIS LINE OF CODE IS NEWLY ADDED

namespace BlazorExample.Server.Controllers
{
	[ApiController]
	[Route("api/[controller]")] // NOTE: THIS LINE OF CODE IS MODIFIED
	public class WeatherForecastController : ControllerBase
	{
		private static readonly string[] Summaries = new[]
		{
			"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
		};

		private readonly ILogger<WeatherForecastController> logger;

		public WeatherForecastController(ILogger<WeatherForecastController> logger)
		{
			this.logger = logger;
		}

		[HttpGet]
		[Authorize] // NOTE: THIS LINE OF CODE IS NEWLY ADDED
		public IEnumerable<WeatherForecast> Get()
		{
			var rng = new Random();
			return Enumerable.Range(1, 10).Select(index => new WeatherForecast
			{
				Date = DateTime.Now.AddDays(index),
				TemperatureC = rng.Next(-20, 55),
				Summary = Summaries[rng.Next(Summaries.Length)]
			});
		}
	}
}

Razor Components

Here is the one razor component created for the client project. Is is called UserComponent and the file is named UserComponent.razor

@inject IJSRuntime jsr

<p>
	@if (string.IsNullOrEmpty(username))
	{
		<span><a href="/register">Register</a> <a href="/login">Login</a></span>
	}
	else
	{
		<span>Hello, @username <a href="/logout">(Logout)</a></span>
	}
</p>

@code {

	string username = string.Empty;

	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();
		var userdata = await jsr.InvokeAsync<string>("localStorage.getItem", "user").ConfigureAwait(false);
		if (!string.IsNullOrWhiteSpace(userdata))
		{
			username = userdata.Split(';', 2)[0];
		}
	}
}

Razor Pages

Here is the Register.razor page for the client project.

@page "/register"
@using BlazorExample.Shared
@inject HttpClient Http

<h3>Register</h3>

<p>@message</p>
<p><a href="/login">@login</a></p>

<EditForm Model="reg" OnValidSubmit="OnValid" style="max-width:500px;">
	<DataAnnotationsValidator />
	<ValidationSummary />
	<div class="mb-2">
		<InputText class="form-control" @bind-Value="reg.email" placeholder="Enter Email"></InputText>
	</div>
	<div class="mb-2">
		<InputText type="password" class="form-control" @bind-Value="reg.password" placeholder="Enter Password"></InputText>
	</div>
	<div class="mb-2">
		<InputText type="password" class="form-control" @bind-Value="reg.confirmpwd" placeholder="Confirm Password"></InputText>
	</div>
	<div class="mb-2 text-right">
		<button class="btn btn-secondary" disabled="@isDisabled">register</button>
	</div>
</EditForm>

@code {
	RegModel reg = new RegModel();
	string message = string.Empty, login = string.Empty;
	bool isDisabled = false;

	private async Task OnValid()
	{
		isDisabled = true;
		using (var msg = await Http.PostAsJsonAsync<RegModel>("/api/auth/register", reg, System.Threading.CancellationToken.None))
		{
			if (msg.IsSuccessStatusCode)
			{
				LoginResult result = await msg.Content.ReadFromJsonAsync<LoginResult>();
				message = result.message;
				if (result.success)
				{
					message += " Please LOGIN to continue.";
					login = "Click here to LOGIN.";
				}
				else
					isDisabled = false;
			}
		}
	}
}

Here is the Login.razor page for the client project.

@page "/login"
@using BlazorExample.Shared
@inject HttpClient Http
@inject IJSRuntime jsr

<h3>Login</h3>

<p>@message</p>

<EditForm Model="user" OnValidSubmit="OnValid" style="max-width:500px;">
	<DataAnnotationsValidator />
	<ValidationSummary />
	<div class="mb-2">
		<InputText class="form-control" @bind-Value="user.email" placeholder="Enter Email"></InputText>
	</div>
	<div class="mb-2">
		<InputText type="password" class="form-control" @bind-Value="user.password" placeholder="Enter Password"></InputText>
	</div>
	<div class="mb-2 text-right">
		<button class="btn btn-secondary" disabled="@isDisabled">login</button>
	</div>
</EditForm>

@code {
	LoginModel user = new LoginModel();
	string message = string.Empty;
	bool isDisabled = false;

	private async Task OnValid()
	{
		isDisabled = true;
		using (var msg = await Http.PostAsJsonAsync<LoginModel>("/api/auth/login", user, System.Threading.CancellationToken.None))
		{
			if (msg.IsSuccessStatusCode)
			{
				LoginResult result = await msg.Content.ReadFromJsonAsync<LoginResult>();
				message = result.message;
				isDisabled = false;
				if (result.success)
					await jsr.InvokeVoidAsync("localStorage.setItem", "user", $"{result.email};{result.jwtBearer}").ConfigureAwait(false);
			}
		}
	}
}

Here is the Logout.razor page for the client project.

@page "/logout"
@inject IJSRuntime jsr
@inject NavigationManager nav

@code {

	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();
		await jsr.InvokeVoidAsync("localStorage.removeItem", "user").ConfigureAwait(false);
		nav.NavigateTo("/");
	}
}

Here is the modified FetchData.razor page for the client project.

@page "/fetchdata"
@using BlazorExample.Shared
@inject HttpClient Http
@inject IJSRuntime jsr

<UserComponent></UserComponent>

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>
@if (string.IsNullOrEmpty(userdata))
{
	<p><a href="/login">LOGIN TO ACCESS THIS DATA</a></p>
}
else if (forecasts == null)
{
	<p><em>Loading...</em></p>
}
else
{
	<table class="table">
		<thead>
			<tr>
				<th>Date</th>
				<th>Temp. (C)</th>
				<th>Temp. (F)</th>
				<th>Summary</th>
			</tr>
		</thead>
		<tbody>
			@foreach (var forecast in forecasts)
			{
				<tr>
					<td>@forecast.Date.ToShortDateString()</td>
					<td>@forecast.TemperatureC</td>
					<td>@forecast.TemperatureF</td>
					<td>@forecast.Summary</td>
				</tr>
			}
		</tbody>
	</table>
}

@code {

	private WeatherForecast[] forecasts;
	string userdata = string.Empty;

	protected override async Task OnInitializedAsync()
	{
		await base.OnInitializedAsync();
		userdata = await jsr.InvokeAsync<string>("localStorage.getItem", "user").ConfigureAwait(false);
		if (!string.IsNullOrWhiteSpace(userdata))
		{
			var dataArray = userdata.Split(';', 2);
			var requestMsg = new HttpRequestMessage(HttpMethod.Get, "/api/weatherforecast");
			requestMsg.Headers.Add("Authorization", "Bearer " + dataArray[1]);
			var response = await Http.SendAsync(requestMsg);
			if (response.IsSuccessStatusCode)
				forecasts = await response.Content.ReadFromJsonAsync<WeatherForecast[]>();
		}
	}

}

Coding Video

https://youtu.be/xukWLqTGqgA