This post cover Blazor WebAssembly Authentication with some customizations, allow full control over authentication process.

UPDATE 28/06/2021 – Project updated and published on GitHub: https://github.com/CodeDesignTips/CustomBlazorAuthentication

The main reason i design this authentication is because the default authentication on Blazor (Identity server 4) had some drawbacks:

  • Does not allow integrate custom database or custom db schema
  • Does not allow custom authentication interface (yes, you can with scaffolding but you need to use server side rendering with old cshtml razor pages)
  • Does not allow full control over authentication process

The following implementation support:

  • ASP.NET Core Identity Authentication for Blazor WebAssembly
  • Custom database provider (this sample use Sql Server but you can integrate any other)
  • Custom database schema
  • Any database framework (you can use EntityFramework, Dapper, ADO.NET or any other)
  • Full control over authentication query
  • JWT Authentication
  • Custom user interface

Table of contents:

Init Blazor WebAssembly project

Let’s start creating a new Blazor App from Visual Studio:

Select Blazor WebAssembly App and make sure to check “ASP.NET Core hosted”

Create user and role Model

Create user and role model with the properties based on your db schema:

public static partial class Model
{ 
    public class User
    {
        #region Properties
        /// <summary>
        /// User id
        /// </summary>
        public Guid UserId { get; set; }
        /// <summary>
        /// Username
        /// </summary>
        [Required]
        public string UserName { get; set; }
        /// <summary>
        /// Password
        /// </summary>
        [Required]
        public string Password { get; set; }
        /// <summary>
        /// Password salt
        /// </summary>
        [SwaggerIgnore]
        public string PasswordSalt { get; set; }
        /// <summary>
        /// Check if the password is encrypted
        /// </summary>
        [SwaggerIgnore]
        public bool IsPasswordEncrypted { get; set; }
        /// <summary>
        /// Password confirm
        /// </summary>
        [Required]
        [Compare("Password")]
        [SwaggerIgnore]
        public string PasswordConfirm { get; set; }
        /// <summary>
        /// Email
        /// </summary>
        [Required]
        [EmailAddress]
        public string Email { get; set; }
        /// <summary>
        /// User role
        /// </summary>
        public UserRole UserRole { get; set; }
        /// <summary>
        /// Name
        /// </summary>
        [Required]
        public string Name { get; set; }
        /// <summary>
        /// Surname
        /// </summary>
        [Required]
        public string Surname { get; set; }
        #endregion

        #region Constructor
        /// <summary>
        /// Constructor
        /// </summary>
        public User()
        {
            UserRole = UserRole.User;
        }
        #endregion
    }
}
public static partial class Model
{ 
    public class Role
    {
        #region Properties
        /// <summary>
        /// Role Id
        /// </summary>
        public Guid RoleId { get; set; }
        /// <summary>
        /// Role name
        /// </summary>
        public string RoleName { get; set; }
        #endregion

        #region Constructor
        /// <summary>
        /// Constructor
        /// </summary>
        public Role()
        {

        }
        #endregion
    }
}

Define password salt and hash generation

public static partial class Utils
{
    #region Password hashing
    /// <summary>
    /// Return the salt to use in password encryption
    /// </summary>
    /// <returns>Salt to use in password encryption</returns>
    public static string GeneratePasswordSalt()
    {
        //base64 salt length:
        //You need 4*(n/3) chars to represent n bytes, and this needs to be rounded up to a multiple of 4.

        var size = 48; // = base64 string of 64 chars
        var random = new RNGCryptoServiceProvider();
        var salt = new byte[size];
        random.GetBytes(salt);
        return Convert.ToBase64String(salt);
    }
    /// <summary>
    /// Return hash of salt + password
    /// </summary>
    /// <param name="password">Password</param>
    /// <param name="salt">Salt</param>
    /// <returns>Hash of (salt + password)</returns>
    public static string GetPasswordHash(string password, string salt)
    {
        //https://crackstation.net/hashing-security.htm
        //http://www.codeproject.com/Questions/1063132/How-to-Match-Hash-with-Salt-Password-in-Csharp

        //base64 salt length:
        //You need 4*(n/3) chars to represent n bytes, and this needs to be rounded up to a multiple of 4.
        //512bit = 64bytes => stringa base64 di 88 caratteri

        var combinedPassword = string.Concat(salt, password);
        var bytes = new UTF8Encoding().GetBytes(combinedPassword);

        byte[] hashBytes;
        using (var algorithm = new System.Security.Cryptography.SHA512Managed())
        {
            hashBytes = algorithm.ComputeHash(bytes);
        }
        return Convert.ToBase64String(hashBytes);
    }
    #endregion
}

Configure JWT Parameters in appSettings.json

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=SERVERNAME;Database=BlazorAuth;Trusted_Connection=True;MultipleActiveResultSets=true"
  },
  "JwtSecurityKey": "RANDOM_KEY_MUST_NOT_BE_SHARED",
  "JwtIssuer": "https://localhost",
  "JwtAudience": "https://localhost",
  "JwtExpiryInDays": 1
}

Define the data layer

Data layer is implemented in DataLayer project, it contain a base class and 2 derived classes for Sql Server and Oracle code. This sample project implement only the structure definition using an in-memory storage to manage users.

A default user: demo with password demo is created by default.

Create the authentication service

Create the authentication service class in the ServiceLayer project

public class AuthenticationService: BaseService
{
    #region Private members
    /// <summary>
    /// Configuration settings
    /// </summary>
    private readonly IConfiguration configuration;
    /// <summary>
    /// Authentication manager
    /// </summary>
    private readonly SignInManager<Model.User> signInManager;
    #endregion

    #region Constructor
    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="configuration">Configuration</param>
    /// <param name="signInManager">Authentication manager</param>
    public AuthenticationService(IConfiguration configuration, SignInManager<Model.User> signInManager)
    {
        this.configuration = configuration;
        this.signInManager = signInManager;
    }
    #endregion

    #region Public methods
    /// <summary>
    /// Manage the authentication
    /// </summary>
    /// <param name="request">Authentication info</param>
    /// <returns>Request result</returns>
    public async Task<(bool Result, string AccessToken)> LoginAsync(string userName, string password)
    {
        var accessToken = "";
        var ret = false;

        try
        { 
            var user = await signInManager.UserManager.FindByNameAsync(userName);
            ret = user != null && user.Password == Utils.GetPasswordHash(password, user.PasswordSalt);
            if (!ret)
                HandleError("User name or password not valid!");
            
            if (ret)
            {
                await signInManager.SignInAsync(user, false);

                var claims = new[]
                {
                    new Claim(ClaimTypes.NameIdentifier, user.UserId.ToString()),
                    new Claim(ClaimTypes.Name, user.UserName),
                    new Claim(ClaimTypes.Role, user.UserRole.ToString())
                };

                var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JwtSecurityKey"]));
                var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
                var expiry = DateTime.Now.AddDays(Convert.ToInt32(configuration["JwtExpiryInDays"]));

                var token = new JwtSecurityToken(
                        configuration["JwtIssuer"],
                        configuration["JwtAudience"],
                        claims,
                        expires: expiry,
                        signingCredentials: creds
                    );

                accessToken = new JwtSecurityTokenHandler().WriteToken(token);
            }
        }
        catch(Exception ex)
        {
            ret = false;
            HandleError(ex.Message);
        }

        return (ret, accessToken);
    }
    /// <summary>
    /// Manage user logout
    /// </summary>
    /// <returns>Request result</returns>
    public async Task<bool> LogoutAsync()
    {
        var ret = false;

        try
        {
            await signInManager.SignOutAsync();
            ret = true;
        }
        catch (Exception ex)
        {
            HandleError(ex.Message);
        }

        return ret;
    }
    #endregion
}

Create the users service

Create the users service class in the ServiceLayer Project

public class UsersService: BaseService
{
    #region Private members
    /// <summary>
    /// User service
    /// </summary>
    private UserManager<Model.User> userManager;
    #endregion

    #region Constructor
    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="userManager">User manager</param>
    public UsersService(UserManager<Model.User> userManager)
    {
        this.userManager = userManager;
    }
    #endregion

    #region Public methods
    /// <summary>
    /// Manage user registration
    /// </summary>
    /// <param name="user">Use info</param>
    /// <returns>Request result</returns>
    public async Task<bool> InsertAsync(Model.User user)
    {
        var ret = false;
        var errorMessage = "";

        try
        {
            var createResult = await userManager.CreateAsync(user);
            ret = createResult.Succeeded;
            if (!ret)
            {
                foreach (var error in createResult.Errors)
                {
                    if (!string.IsNullOrEmpty(errorMessage))
                        errorMessage += "\n";
                    errorMessage += error.Description;
                }

                HandleError(errorMessage);
            }
        }
        catch (Exception ex)
        {
            HandleError(ex.Message);
        }

        return ret;
    }
    /// <summary>
    /// Manage user delete
    /// </summary>
    /// <param name="userId">Use id</param>
    /// <returns>Request result</returns>
    public async Task<bool> RemoveAsync(Guid userId)
    {
        var ret = false;
        var errorMessage = "";

        try
        {
            var user = await userManager.FindByIdAsync(userId.ToString());
            ret = user != null;
            if (!ret)
                HandleError("User not found!");
            else
            {
                ret = false;
                var deleteResult = await userManager.DeleteAsync(user);
                ret = deleteResult.Succeeded;
                if (!ret)
                {
                    foreach (var error in deleteResult.Errors)
                    {
                        if (!string.IsNullOrEmpty(errorMessage))
                            errorMessage += "\n";
                        errorMessage += error.Description;
                    }

                    HandleError(errorMessage);
                }
            }
        }
        catch (Exception ex)
        {
            HandleError(ex.Message);
        }

        return ret;
    }
    #endregion
}

Create the authentication controller

Add a new Api controller named AuthenticationController.

[Route("[controller]/[action]")]
public class AuthenticationController : BaseController
{
    #region Private members
    /// <summary>
    /// Authentication service
    /// </summary>
    private readonly AuthenticationService authenticationService;
    #endregion

    #region Constructor
    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="configuration">Configuration settings</param>
    /// <param name="signInManager">Authentication manager</param>
    public AuthenticationController(IConfiguration configuration, SignInManager<Model.User> signInManager)
    {
        authenticationService = new AuthenticationService(configuration, signInManager);
    }
    #endregion

    #region Methods
    /// <summary>
    /// Manage user authentication
    /// </summary>
    /// <param name="request">Authentication info</param>
    /// <returns>Request result</returns>
    /// <response code="200">Request completed successfully</response>
    /// <response code="400">Request failed</response>
    [HttpPost]
    [ProducesResponseType(typeof(Model.LoginResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(Model.LoginResponse), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Login(Model.LoginRequest request)
    {
        var result = await authenticationService.LoginAsync(request.UserName, request.Password);

        var loginResponse = new Model.LoginResponse
        {
            Result = result.Result,
            AccessToken = result.AccessToken,
            ErrorMessage = authenticationService.ErrorMessage
        };

        if (!result.Result)
            return BadRequest(loginResponse);

        return Ok(loginResponse);
    }
    /// <summary>
    /// Manage user logout
    /// </summary>
    /// <returns>Request result</returns>
    /// <response code="200">Request completed successfully</response>
    /// <response code="400">Request failed</response>
    [Authorize]
    [HttpPost]
    [ProducesResponseType(typeof(Model.LogoutResponse), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(Model.LogoutResponse), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Logout()
    {
        var errorMessage = "";
        var ret = await authenticationService.LogoutAsync();
        if (!ret)
            errorMessage = authenticationService.ErrorMessage;

        var result = new Model.LogoutResponse
        {
            Result = ret,
            ErrorMessage = errorMessage
        };

        if (!result.Result)
            return BadRequest(result);

        return Ok(result);
    }
    #endregion
}

Create the users controller

[Route("[controller]")]
public class UsersController : BaseController
{
	#region Private members
	/// <summary>
	/// User service
	/// </summary>
	private UsersService usersService;
	#endregion

	#region Constructor
	/// <summary>
	/// Constructor
	/// </summary>
	/// <param name="userManager">User manager</param>
	public UsersController(UserManager<Model.User> userManager)
	{
		usersService = new UsersService(userManager);
	}
	#endregion

	#region Methods
	/// <summary>
	/// Manage user insert
	/// </summary>
	/// <param name="user"></param>
	/// <returns>Request result</returns>
	/// <response code="200">Request completed successfully</response>
	/// <response code="400">Request failed</response>
	[HttpPost]
	[ProducesResponseType(typeof(Model.UsersPostResponse), StatusCodes.Status200OK)]
	[ProducesResponseType(typeof(Model.UsersPostResponse), StatusCodes.Status400BadRequest)]
	public async Task<IActionResult> Post(Model.User user)
	{
		if (UserRole == Model.UserRole.User)
			user.UserRole = Model.UserRole.User;

		var errorMessage = "";
		var ret = await usersService.InsertAsync(user);
		if (!ret)
			errorMessage = usersService.ErrorMessage;

		var result = new Model.UsersPostResponse
		{
			Result = ret,
			ErrorMessage = errorMessage
		};

		if (!result.Result)
			return BadRequest(result);

		return Ok(result);
	}
	/// <summary>
	/// Manage user delete
	/// </summary>
	/// <param name="userId">User id</param>
	/// <returns>Request result</returns>
	/// <response code="200">Request completed successfully</response>
	/// <response code="400">Request failed</response>
	[Authorize(Roles = "Administrator")]
	[HttpDelete]
	[ProducesResponseType(typeof(Model.UsersPostResponse), StatusCodes.Status200OK)]
	[ProducesResponseType(typeof(Model.UsersPostResponse), StatusCodes.Status400BadRequest)]
	public async Task<IActionResult> Delete(Guid userId)
	{
		var errorMessage = "";
		var ret = await usersService.RemoveAsync(userId);
		if (!ret)
			errorMessage = usersService.ErrorMessage;

		var result = new Model.UsersDeleteResponse
		{
			Result = ret,
			ErrorMessage = errorMessage
		};

		if (!result.Result)
			return BadRequest(result);

		return Ok(result);
	}
	#endregion
}

Customize ASP.NET Core Identity

To allow custom db and custom db schema we have to implement a class for the ASP.NET identity interfaces IUserStore and IPassworHasher.

Implement UserStore class:

public static partial class CustomIdentity
{ 
	public class UserStore : IUserStore<Model.User>, IUserPasswordStore<Model.User>
	{
		#region Properties
		/// <summary>
		/// Provider name
		/// </summary>
		protected string ProviderName { get; private set; }
		/// <summary>
		/// Connection string
		/// </summary>
		protected string ConnectionString { get; private set; }
		#endregion

		#region Constructor
		/// <summary>
		/// Constructor
		/// </summary>
		public UserStore() : base()
		{

		}
		/// <summary>
		/// Constructor
		/// </summary>
		/// <param name="providerName">Provider name</param>
		/// <param name="connectionString">Connection string</param>
		public UserStore(string providerName, string connectionString)
		{
			ProviderName = providerName;
			ConnectionString = connectionString;
		}
		#endregion

		#region Methods
		//
		// Summary:
		//     Creates the specified user in the user store.
		//
		// Parameters:
		//   user:
		//     The user to create.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the Microsoft.AspNetCore.Identity.IdentityResult of the creation operation.
		public Task<IdentityResult> CreateAsync(Model.User user, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			if (!user.IsPasswordEncrypted)
			{
				user.PasswordSalt = Utils.GeneratePasswordSalt();
				user.Password = Utils.GetPasswordHash(user.Password, user.PasswordSalt);
				user.IsPasswordEncrypted = true;
			}

			var ret = false;
			var errorMessage = "";
			using (var db = DbLayer.CreateObject(ProviderName, ConnectionString))
			{
				ret = db.InsertUser(user);
				if (!ret)
					errorMessage = db.ErrorMessage;
			}

			if (ret)
				return Task.FromResult(IdentityResult.Success);

			return Task.FromResult(IdentityResult.Failed(new IdentityError { Description = errorMessage }));
		}
		//
		// Summary:
		//     Deletes the specified user from the user store.
		//
		// Parameters:
		//   user:
		//     The user to delete.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the Microsoft.AspNetCore.Identity.IdentityResult of the update operation.
		public Task<IdentityResult> DeleteAsync(Model.User user, CancellationToken cancellationToken)
		{
			//Not supported
			return Task.FromResult(IdentityResult.Failed());
		}
		//
		// Summary:
		//     Finds and returns a user, if any, who has the specified userId.
		//
		// Parameters:
		//   userId:
		//     The user ID to search for.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the user matching the specified userId if it exists.
		public Task<Model.User> FindByIdAsync(string userId, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (!Guid.TryParse(userId, out var userIdValue))
				throw new ArgumentException("Not a valid Guid id", nameof(userId));

			using (var db = DbLayer.CreateObject(ProviderName, ConnectionString))
			{
				var user = db.GetUser(userId);

				return Task.FromResult(user);
			}
		}
		//
		// Summary:
		//     Finds and returns a user, if any, who has the specified normalized user name.
		//
		// Parameters:
		//   normalizedUserName:
		//     The normalized user name to search for.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the user matching the specified normalizedUserName if it exists.
		public Task<Model.User> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			using (var db = DbLayer.CreateObject(ProviderName, ConnectionString))
			{
				var user = db.GetUser(normalizedUserName);
				return Task.FromResult(user);
			}
		}
		//
		// Summary:
		//     Gets the normalized user name for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose normalized name should be retrieved.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the normalized user name for the specified user.
		public Task<string> GetNormalizedUserNameAsync(Model.User user, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			return Task.FromResult(user.UserName);
		}
		//
		// Summary:
		//     Gets the user identifier for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose identifier should be retrieved.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the identifier for the specified user.
		public Task<string> GetUserIdAsync(Model.User user, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			return Task.FromResult(user.UserId.ToString());
		}
		//
		// Summary:
		//     Gets the user name for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose name should be retrieved.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the name for the specified user.
		public Task<string> GetUserNameAsync(Model.User user, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			return Task.FromResult(user.UserName);
		}
		//
		// Summary:
		//     Sets the given normalized name for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose name should be set.
		//
		//   normalizedName:
		//     The normalized name to set.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation.
		public Task SetNormalizedUserNameAsync(Model.User user, string normalizedName, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			return Task.FromResult<object>(null);
		}
		//
		// Summary:
		//     Sets the given userName for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose name should be set.
		//
		//   userName:
		//     The user name to set.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation.
		public Task SetUserNameAsync(Model.User user, string userName, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			user.UserName = userName;

			return Task.FromResult<object>(null);
		}
		//
		// Summary:
		//     Updates the specified user in the user store.
		//
		// Parameters:
		//   user:
		//     The user to update.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, containing
		//     the Microsoft.AspNetCore.Identity.IdentityResult of the update operation.
		public Task<IdentityResult> UpdateAsync(Model.User user, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			//Not supported
			return Task.FromResult(IdentityResult.Failed());
		}

		//
		// Summary:
		//     Gets the password hash for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose password hash to retrieve.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, returning
		//     the password hash for the specified user.
		public Task<string> GetPasswordHashAsync(Model.User user, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			if (string.IsNullOrEmpty(user.Password))
				throw new ArgumentNullException(nameof(user.Password));

			if (user.IsPasswordEncrypted)
				return Task.FromResult(user.Password);

			user.PasswordSalt = Utils.GeneratePasswordSalt();
			user.Password = Utils.GetPasswordHash(user.Password, user.PasswordSalt);
			user.IsPasswordEncrypted = true;

			return Task.FromResult(user.Password);
		}
		//
		// Summary:
		//     Gets a flag indicating whether the specified user has a password.
		//
		// Parameters:
		//   user:
		//     The user to return a flag for, indicating whether they have a password or not.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation, returning
		//     true if the specified user has a password otherwise false.
		public Task<bool> HasPasswordAsync(Model.User user, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			return Task.FromResult(!string.IsNullOrEmpty(user.Password));
		}
		//
		// Summary:
		//     Sets the password hash for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose password hash to set.
		//
		//   passwordHash:
		//     The password hash to set.
		//
		//   cancellationToken:
		//     The System.Threading.CancellationToken used to propagate notifications that the
		//     operation should be canceled.
		//
		// Returns:
		//     The System.Threading.Tasks.Task that represents the asynchronous operation.
		public Task SetPasswordHashAsync(Model.User user, string passwordHash, CancellationToken cancellationToken)
		{
			cancellationToken.ThrowIfCancellationRequested();

			if (user == null)
				throw new ArgumentNullException(nameof(user));

			user.Password = passwordHash;
			user.IsPasswordEncrypted = true;

			return Task.FromResult<object>(null);
		}

		public void Dispose()
		{

		}
		#endregion
	}
}

Implement PasswordHasher class:

public static partial class CustomIdentity
{
	public class PasswordHasher : IPasswordHasher<Model.User>
	{
		//
		// Summary:
		//     Returns a hashed representation of the supplied password for the specified user.
		//
		// Parameters:
		//   user:
		//     The user whose password is to be hashed.
		//
		//   password:
		//     The password to hash.
		//
		// Returns:
		//     A hashed representation of the supplied password for the specified user.
		public string HashPassword(Model.User user, string password)
		{
			if (user == null)
				throw new ArgumentNullException(nameof(user));

			if (string.IsNullOrEmpty(password))
				throw new ArgumentNullException(nameof(password));

			user.PasswordSalt = Utils.GeneratePasswordSalt();
			user.Password = Utils.GetPasswordHash(password, user.PasswordSalt);
			user.IsPasswordEncrypted = true;

			return user.Password;
		}
		//
		// Summary:
		//     Returns a Microsoft.AspNetCore.Identity.PasswordVerificationResult indicating
		//     the result of a password hash comparison.
		//
		// Parameters:
		//   user:
		//     The user whose password should be verified.
		//
		//   hashedPassword:
		//     The hash value for a user's stored password.
		//
		//   providedPassword:
		//     The password supplied for comparison.
		//
		// Returns:
		//     A Microsoft.AspNetCore.Identity.PasswordVerificationResult indicating the result
		//     of a password hash comparison.
		//
		// Remarks:
		//     Implementations of this method should be time consistent.
		public PasswordVerificationResult VerifyHashedPassword(Model.User user, string hashedPassword, string providedPassword)
		{
			if (user == null)
				throw new ArgumentNullException(nameof(user));

			if (string.IsNullOrEmpty(hashedPassword))
				throw new ArgumentNullException(nameof(hashedPassword));

			if (string.IsNullOrEmpty(providedPassword))
				throw new ArgumentNullException(nameof(providedPassword));

			var password = Utils.GetPasswordHash(providedPassword, user.PasswordSalt);

			if (password.Equals(hashedPassword))
				return PasswordVerificationResult.Success;

			return PasswordVerificationResult.Failed;
		}
	}
}

Update server Startup.cs to configure the authentication

public class Startup
{
	#region Properties
	/// <summary>
	/// Configuration
	/// </summary>
	public IConfiguration Configuration { get; }
	/// <summary>
	/// Provider name
	/// </summary>
	public string ProviderName
	{
		get
		{
			return "System.Data.SqlClient";
		}
	}
	/// <summary>
	/// Connection string
	/// </summary>
	public string ConnectionString
	{
		get
		{
			return Configuration["ConnectionStrings:DefaultConnection"];
		}
	}
	#endregion

	#region Constructor
	/// <summary>
	/// Constructor
	/// </summary>
	/// <param name="configuration"></param>
	public Startup(IConfiguration configuration)
	{
		Configuration = configuration;
	}
	#endregion

	// 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)
	{
		//ASP.NET Core Identity Authentication
		var identityBuilder = services.AddIdentity<Model.User, Model.Role>(options => {
			//Password validation criteria
			options.SignIn.RequireConfirmedAccount = false;
			options.Password.RequireDigit = false;
			options.Password.RequireLowercase = false;
			options.Password.RequireNonAlphanumeric = false;
			options.Password.RequireUppercase = false;
		});
		identityBuilder.AddDefaultTokenProviders();

		//UserStore management
		services.AddTransient<IPasswordHasher<Model.User>, CustomIdentity.PasswordHasher>();
		services.AddTransient<IUserStore<Model.User>, CustomIdentity.UserStore>(obj => new CustomIdentity.UserStore(ProviderName, ConnectionString));
		services.AddTransient<IRoleStore<Model.Role>, CustomIdentity.RoleStore>(obj => new CustomIdentity.RoleStore(ProviderName, ConnectionString));

		//JWT Bearer token authentication
		services.AddAuthentication(options => {
			//Set default JwtBearer authentication
			options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
		})
				.AddJwtBearer(options =>
				{
					options.TokenValidationParameters = new TokenValidationParameters
					{
						ValidateIssuer = true,
						ValidateAudience = true,
						ValidateLifetime = true,
						ValidateIssuerSigningKey = true,
						ValidIssuer = Configuration["JwtIssuer"],
						ValidAudience = Configuration["JwtAudience"],
						IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtSecurityKey"]))
					};
				});

		//Set Default JwtBearer authorization
		services.AddAuthorization(options => {
			var defaultAuthorizationPolicyBuilder = new AuthorizationPolicyBuilder(JwtBearerDefaults.AuthenticationScheme);
			defaultAuthorizationPolicyBuilder = defaultAuthorizationPolicyBuilder.RequireAuthenticatedUser();
			options.DefaultPolicy = defaultAuthorizationPolicyBuilder.Build();
		});

		//Configure json serialization
		services.AddControllersWithViews()
			.AddJsonOptions(options => {
				options.JsonSerializerOptions.PropertyNamingPolicy = null;
				options.JsonSerializerOptions.DictionaryKeyPolicy = null;
			});

		services.AddRazorPages();
	}

	// 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");
			// 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.UseBlazorFrameworkFiles();
		app.UseStaticFiles();

		app.UseRouting();
		app.UseAuthentication();
		app.UseAuthorization();

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

Implement client custom AuthenticationStateProvider

In the client side we have to implement a custom Authentication state provider, so we add a class who inherit AutenticatinStateProvider

public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
	#region Private members
	/// <summary>
	/// HttpService to manage http requests
	/// </summary>
	private readonly HttpService httpService;
	#endregion

	#region Constructor
	/// <summary>
	/// Constructor
	/// </summary>
	/// <param name="httpService">HttpService to manage http requests</param>
	public CustomAuthenticationStateProvider(HttpService httpService)
	{
		this.httpService = httpService;
	}
	#endregion

	#region Public methods
	/// <summary>
	/// Return authentication info
	/// </summary>
	/// <returns>Authentication info</returns>
	public override async Task<AuthenticationState> GetAuthenticationStateAsync()
	{
		var accessToken = await httpService.Authentication.GetAccessTokenAsync();
		if (string.IsNullOrWhiteSpace(accessToken))
			return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));

		return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(Utils.ParseClaimsFromJwt(accessToken), "jwt")));
	}
	/// <summary>
	/// Manage user login
	/// </summary>
	/// <param name="loginRequest"></param>
	/// <returns>Login response info</returns>
	public async Task<Model.LoginResponse> LoginAsync(Model.LoginRequest loginRequest)
	{
		var result = await httpService.Authentication.LoginAsync(loginRequest);
		if (result.Result)
		{
			var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, loginRequest.UserName) }, "apiauth"));
			var authState = Task.FromResult(new AuthenticationState(authenticatedUser));
			NotifyAuthenticationStateChanged(authState);
		}

		return result;
	}
	/// <summary>
	/// Manage user logout
	/// </summary>
	/// <returns>Logout response info</returns>
	public async Task<Model.LogoutResponse> LogoutAsync()
	{
		var result = await httpService.Authentication.LogoutAsync();
		if (result.Result)
		{
			var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
			var authState = Task.FromResult(new AuthenticationState(anonymousUser));
			NotifyAuthenticationStateChanged(authState);
		}

		return result;
	}
	/// <summary>
	/// Manage user registration
	/// </summary>
	/// <param name="user">User info</param>
	/// <returns>Request result</returns>
	public async Task<Model.UsersPostResponse> RegisterAsync(Model.User user)
	{
		return await httpService.Users.PostAsync(user);
	}
	#endregion
}

Implement client http service

Add a class to implement the http service

public partial class AuthenticationHttpService: HttpService
{
	#region Private members
	protected readonly ILocalStorageService m_localStorage;
	#endregion

	#region Properties
	/// <summary>
	/// Indicates whether to use LocalStorage to read / store access token
	/// </summary>
	public bool UseLocalStorageForAccessToken { get; set; }
	#endregion

	#region Costruttore
	/// <summary>
	/// Constructor
	/// </summary>
	/// <param name="httpClient"></param>
	/// <param name="localStorage"></param>
	public AuthenticationHttpService(HttpClient httpClient, ILocalStorageService localStorage) : base(httpClient)
	{
		m_localStorage = localStorage;
	}
	#endregion

	#region Private methods
	/// <summary>
	/// Set access token in http header
	/// </summary>
	/// <param name="accessToken">Access token</param>
	private async Task SetAccessTokenAsync(string accessToken)
	{
		if (!string.IsNullOrEmpty(accessToken))
		{
			httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", accessToken);

			if (UseLocalStorageForAccessToken)
				await m_localStorage.SetItemAsync("accessToken", accessToken);
		}
		else
		{
			httpClient.DefaultRequestHeaders.Authorization = null;

			if (UseLocalStorageForAccessToken)
				await m_localStorage.RemoveItemAsync("accessToken");
		}
	}
	#endregion

	#region Public methods
	/// <summary>
	/// Return access token
	/// </summary>
	/// <returns></returns>
	public async Task<string> GetAccessTokenAsync()
	{
		//Read token from object
		if (httpClient.DefaultRequestHeaders.Authorization != null)
		{
			if (!string.IsNullOrEmpty(httpClient.DefaultRequestHeaders.Authorization.Parameter))
				return httpClient.DefaultRequestHeaders.Authorization.Parameter;
		}

		//Read token from LocalStorage
		var accessToken = "";
		if (UseLocalStorageForAccessToken)
			accessToken = await m_localStorage.GetItemAsync<string>("accessToken");

		return accessToken;
	}
	/// <summary>
	/// Manage login
	/// </summary>
	/// <param name="loginRequest"></param>
	/// <returns></returns>
	public async Task<Model.LoginResponse> LoginAsync(Model.LoginRequest loginRequest)
	{
		Model.LoginResponse result = null;

		try
		{
			var response = await httpClient.PostAsJsonAsync("authentication/login", loginRequest);
			result = await CheckJsonResponseAsync<Model.LoginResponse>(response);
		}
		catch (Exception ex)
		{
			ErrorMessage = ex.Message;
		}

		if (result != null && result.Result)
			await SetAccessTokenAsync(result.AccessToken);

		return result;
	}
	/// <summary>
	/// Manage logout
	/// </summary>
	/// <returns>Request response</returns>
	public async Task<Model.LogoutResponse> LogoutAsync()
	{
		Model.LogoutResponse result = null;

		try
		{
			var response = await httpClient.PostAsync("authentication/logout", null);
			result = await CheckJsonResponseAsync<Model.LogoutResponse>(response);
		}
		catch (Exception ex)
		{
			ErrorMessage = ex.Message;
		}

		if (result != null && result.Result)
			await SetAccessTokenAsync(null);

		return result;
	}
	#endregion
}
public partial class UsersHttpService: HttpService
{
	#region Costruttore
	/// <summary>
	/// Constructor
	/// </summary>
	/// <param name="httpClient"></param>
	public UsersHttpService(HttpClient httpClient) : base(httpClient)
	{
	}
	#endregion

	#region Public methods
	/// <summary>
	/// Inser user
	/// </summary>
	/// <param name="user">User info</param>
	/// <returns>Request response</returns>
	public async Task<Model.UsersPostResponse> PostAsync(Model.User user)
	{
		Model.UsersPostResponse result = null;

		try
		{
			var response = await httpClient.PostAsJsonAsync("users", user);
			result = await CheckJsonResponseAsync<Model.UsersPostResponse>(response);
		}
		catch (Exception ex)
		{
			ErrorMessage = ex.Message;
		}

		return result;
	}
	/// <summary>
	/// Remove user
	/// </summary>
	/// <returns>Request response</returns>
	public async Task<Model.UsersDeleteResponse> DeleteAsync(Guid userId)
	{
		Model.UsersDeleteResponse result = null;

		try
		{
			var response = await httpClient.DeleteAsync($"users?userId={userId}");
			result = await CheckJsonResponseAsync<Model.UsersDeleteResponse>(response);
		}
		catch (Exception ex)
		{
			ErrorMessage = ex.Message;
		}

		return result;
	}
	#endregion
}

Update the razor pages

To implement authentication we have to implement some pages to manage authentication (Login.razor, LoginDisplay.razor, RedirectToLogin.razor, Register.razor)

Login.razor

@page "/login"

<div id="login">
    <div class="container">
        <!-- Title -->
        <div class="row">
            <div class="col-sm">
                <h1>Login</h1>
            </div>
        </div>
        <div class="row">
            <EditForm EditContext="@EditContext" OnSubmit="@Authenticate">
                <DataAnnotationsValidator />

                <!-- User -->
                <div class="form-group">
                    <label for="userName">User</label>
                    <InputText id="userName" class="form-control" @bind-Value="@UserName" />
                    <ValidationMessage For="@(() => UserName)" />
                </div>
                <!-- Password -->
                <div class="form-group">
                    <label for="password">Password</label>
                    <InputText type="password" id="password" class="form-control" @bind-Value="@Password" />
                    <ValidationMessage For="@(() => Password)" />
                </div>
                <!-- Action -->
                <button type="submit" class="btn btn-primary">Login</button>
            </EditForm>
        </div>
        <!-- Error -->
        <div class="row mt-1">
            <label>@ErrorMessage</label>
        </div>
    </div>
</div>

Login.razor.cs

public partial class Login : ComponentBase
{
	#region Services
	/// <summary>
	/// Manage page navigation
	/// </summary>
	[Inject]
	private NavigationManager Navigation { get; set; }
	/// <summary>
	/// Manage authentication
	/// </summary>
	[Inject]
	private CustomAuthenticationStateProvider AuthStateProvider { get; set; }
	#endregion

	#region Proprties
	/// <summary>
	/// Contesto di modifica del form
	/// </summary>
	private EditContext EditContext { get; set; }
	/// <summary>
	/// User name
	/// </summary>
	[Required]
	public string UserName { get; set; }
	/// <summary>
	/// Password
	/// </summary>
	[Required]
	public string Password { get; set; }
	/// <summary>
	/// Error message
	/// </summary>
	private string ErrorMessage { get; set; }
	#endregion

	#region Constructor
	/// <summary>
	/// Constructor
	/// </summary>
	public Login()
	{
		EditContext = new EditContext(this);
	}
	#endregion

	#region Methods
	/// <summary>
	/// Manage user login
	/// </summary>
	private async void Authenticate()
	{
		//Data validation
		if (!EditContext.Validate())
			return;

		var loginRequest = new Model.LoginRequest
		{
			UserName = UserName,
			Password = Password
		};

		//Set return url from querystring param
		var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
		var returnUrl = "/";
		if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("returnUrl", out var param))
			returnUrl = param.First();

		//Login
		var result = await AuthStateProvider.LoginAsync(loginRequest);
		if (result.Result)
			Navigation.NavigateTo(returnUrl);
		else
		{ 
			ErrorMessage = result.ErrorMessage;
			StateHasChanged();
		}
	}
	#endregion
}

LoginDisplay.razor

@using Microsoft.AspNetCore.Components.Authorization
 
<AuthorizeView>
    <Authorized>
        <a href="/profile">Welcome  @context.User.Identity.Name</a>
        <button class="nav-link btn btn-link" @onclick="Logout">Logout</button>
    </Authorized>
    <NotAuthorized>
        <a href="/register">Signup</a>
        <a href="/login">Login</a>
    </NotAuthorized>
</AuthorizeView>

LoginDisplay.razor.cs

public partial class LoginDisplay : ComponentBase
{
	#region Services
	/// <summary>
	/// Manage page navigation
	/// </summary>
	[Inject]
	private NavigationManager NavigationManager { get; set; }
	/// <summary>
	/// Manage authentication
	/// </summary>
	[Inject]
	private CustomAuthenticationStateProvider AuthStateProvider { get; set; }
	#endregion

	/// <summary>
	/// Manage logout
	/// </summary>
	/// <param name="args"></param>
	/// <returns></returns>
	private async Task Logout(MouseEventArgs args)
	{
		var result = await AuthStateProvider.LogoutAsync();
		if (result.Result)
			NavigationManager.NavigateTo("/");
	}
}

RedirectToLogin.razor

@inject NavigationManager Navigation
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@code {
    protected override void OnInitialized()
@inject NavigationManager Navigation
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}");
    }
}

Register.razor

@page "/register"

<div id="register">
    <div class="container">
        <!-- Title -->
        <div class="row">
            <div class="col-sm">
                <h1>Register user</h1>
            </div>
        </div>
        <div class="row">
            <EditForm @ref="Form" Model="@User" OnSubmit="@RegisterUser">
                <DataAnnotationsValidator />

                <!-- User / Email -->
                <div class="form-row">
                    <!-- User -->
                    <div class="col form-group">
                        <label for="userName">User</label>
                        <InputText id="userName" class="form-control" @bind-Value="User.UserName" />
                        <ValidationMessage For="@(() => User.UserName)" />
                    </div>
                    <!-- Email -->
                    <div class="col form-group">
                        <label for="email">Email</label>
                        <InputText id="email" class="form-control" @bind-Value="@User.Email" />
                        <ValidationMessage For="@(() => User.Email)" />
                    </div>
                </div>
                <!-- Password / PasswordConfirm -->
                <div class="form-row">
                    <!-- Password -->
                    <div class="col form-group">
                        <label for="password">Password</label>
                        <InputText id="password" type="password" class="form-control" @bind-Value="@User.Password" />
                        <ValidationMessage For="@(() => User.Password)" />
                    </div>
                    <!-- PasswordConfirm -->
                    <div class="col form-group">
                        <label for="passwordConfirm">Password confirm</label>
                        <InputText id="passwordConfirm" type="password" class="form-control" @bind-Value="@User.PasswordConfirm" />
                        <ValidationMessage For="@(() => User.PasswordConfirm)" />
                    </div>
                </div>
                <!-- Name / Surname -->
                <div class="form-row">
                    <!-- Name -->
                    <div class="col form-group">
                        <label for="name">Name</label>
                        <InputText id="name" class="form-control" @bind-Value="@User.Name" />
                        <ValidationMessage For="@(() => User.Name)" />
                    </div>
                    <!-- Surname -->
                    <div class="col form-group">
                        <label for="surname">Surname</label>
                        <InputText id="surname" class="form-control" @bind-Value="@User.Surname" />
                        <ValidationMessage For="@(() => User.Surname)" />
                    </div>
                </div>
                <!-- Action -->
                <button type="submit" class="btn btn-primary">Register</button>
            </EditForm>
        </div>
        <!-- Message -->
        <div class="row">
            <label>@Message</label>
        </div>
    </div>
</div>

Register.razor.cs

public partial class Register : ComponentBase
{
	#region Services
	/// <summary>
	/// Manage authentication
	/// </summary>
	[Inject]
	private CustomAuthenticationStateProvider AuthStateProvider { get; set; }
	#endregion

	#region Properties
	public EditForm Form { get; set; }
	/// <summary>
	/// Contesto di modifica del form
	/// </summary>
	public EditContext EditContext { get; set; }
	/// <summary>
	/// User info
	/// </summary>
	public Model.User User { get; set; }
	/// <summary>
	/// Error message
	/// </summary>
	private string Message { get; set; }
	#endregion

	#region Constructor
	/// <summary>
	/// Constructor
	/// </summary>
	public Register()
	{
		User = new Model.User();
	}
	#endregion

	#region Methods
	/// <summary>
	/// Manage component initialization
	/// </summary>
	protected override void OnInitialized()
	{
		base.OnInitialized();

		User = new Model.User();
	}
	/// <summary>
	/// Manage user registration
	/// </summary>
	private async Task RegisterUser()
	{
		//Data validation
		if (!Form.EditContext.Validate())
			return;

		var result = await AuthStateProvider.RegisterAsync(User);
		if (result == null || !result.Result)
			Message = $"Registration failed: {result?.ErrorMessage}";
		else
			Message = "Registration completed successfully!";

		StateHasChanged();
	}
	#endregion
}

Conclusions

With some settings we can manage authentication process using ASP.NET Core Identity having full control over authentication process, data layer and user interface.

References

https://github.com/dotnet/AspNetCore.Docs/tree/master/aspnetcore/security/authentication/identity-custom-storage-providers/sample/CustomIdentityProviderSample

https://zogface.blog/2019/04/17/asp-net-core-identity-with-a-custom-data-store/

https://www.eximiaco.tech/en/2019/07/27/writing-an-asp-net-core-identity-storage-provider-from-scratch-with-ravendb/

https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity-custom-storage-providers?view=aspnetcore-3.1

https://www.codewithmukesh.com/blog/authentication-in-blazor-webassembly/

https://chrissainty.com/securing-your-blazor-apps-authentication-with-clientside-blazor-using-webapi-aspnet-core-identity/


6 Comments

AllTechGeeks · October 11, 2020 at 4:08 pm

Hi Claudio,

I am really appreciate on this article. I am trying from last 7 weeks and learning the Blazor but coming to authentication with JWT got lot of problems. Now I am using this code in my sample project

Thanks
Murali

Alessio Iafrate · October 14, 2020 at 5:51 am

Hi, good articles bus some class like LoginResponse, RegisterResponse are missing. Can you add in the article or add a github repository with full code?
Thanks!!

    Claudio Gamberini · October 14, 2020 at 6:04 pm

    In the article i have omitted some class related to data model and db layer to keep the post shortly and more readable. Hope to add a GitHub project soon.

      Pedro Rossi · February 18, 2021 at 12:06 pm

      Can you add a github repository with full code?

Claudio Gamberini · June 28, 2021 at 10:02 pm

Hi, i have updated the post and published the project on GitHub: https://github.com/CodeDesignTips/CustomBlazorAuthentication

Leave a Reply

Your email address will not be published. Required fields are marked *