-
Notifications
You must be signed in to change notification settings - Fork 0
BSP & AuthenticationStateProvider
In this tutorial, you will learn how to connect Blazor’s authentication service to BSP in order to synchronize the user’s Claims with their session using the traditional flow: login, logout, and website access (revisiting the website).
In an application that manages multiple sessions, it is useful to store the identifiers for those session keys to mitigate the risk of introducing a non-existent key, avoiding unnecessary lookups or potential data loss.
You can store each key’s identifier inside a static class with constants:
public static class AuthIdentifiers
{
public const string AuthenticationScheme = "Auth";
public const string ClaimIdUser = "idUser";
public const string ClaimUserName = "userName";
public const string ClaimEmail = "email";
}Or you can store them using a “type-safe enum pattern” if you need complex operations:
public class AuthIdentifiers
{
public static readonly AuthIdentifiers AuthenticationScheme = new("Auth");
public static readonly AuthIdentifiers ClaimIdUser = new("idUser");
...
public string Value { get; }
private AuthIdentifiers(string value) => Value = value;
public override string ToString() => Value;
public static implicit operator string(AuthIdentifiers x) => x.Value;
}To store user information, create a User class that inherits from SessionBinder. Each of its properties will become a separate session value, which will be useful later:
public class User : SessionBinder
{
[BindToKey(AuthIdentifiers.ClaimIdUser)]
public string IdUser
{
get => GetValue<string>();
set => SetValue(value);
}
[BindToKey(AuthIdentifiers.ClaimUserName)]
public string UserName
{
get => GetValue<string>();
set => SetValue(value);
}
[BindToKey(AuthIdentifiers.ClaimEmail)]
public string Email
{
get => GetValue<string>();
set => SetValue(value);
}
[BindToKey]
public List<string> Roles
{
get => GetValue<List<string>>();
set => SetValue(value);
}
public User() : base() { }
public ClaimsPrincipal ToClaimsPrincipal() =>
new(new ClaimsIdentity(new Claim[]
{
new (AuthIdentifiers.ClaimUserName, UserName),
new (AuthIdentifiers.ClaimEmail, Email),
new (AuthIdentifiers.ClaimIdUser, IdUser),
}.Concat(Roles.Select(r => new Claim(ClaimTypes.Role, r)).ToArray()), AuthIdentifiers.AuthenticationScheme));
public static User FromClaimsPrincipal(ClaimsPrincipal principal) =>
new(
principal.FindFirstValue(AuthIdentifiers.ClaimIdUser) ?? "",
principal.FindFirstValue(AuthIdentifiers.ClaimUserName) ?? "",
principal.FindFirstValue(AuthIdentifiers.ClaimEmail) ?? "",
principal.FindAll(ClaimTypes.Role).Select(c => c.Value).ToList()
);
}Important
Make sure to set the authentication scheme name of your application when generating theClaimsIdentity, as it will be required for proper authorization and authentication.Adjust your user class properties to match your database schema.
Create a class that inherits AuthenticationStateProvider. Inside it, inject the ISessionProvider and SessionBindingService, using the User class as the base:
public class MyAuthStateProvider : AuthenticationStateProvider
{
private readonly SessionBindingService<User> _bindingUser;
private readonly ISessionProvider _provider;
public MyAuthStateProvider(
SessionBindingService<User> bindingUser,
ISessionProvider provider)
{
_bindingUser = bindingUser;
_provider = provider;
}
}Also create a class that inherits AuthenticationHandler<AuthenticationSchemeOptions> to validate access to the application using the session. This transient service will also use SessionBindingService<User>:
public class MyAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly SessionBindingService<User> _bindingUser;
public EXaberAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
SessionBindingService<User> bindingUser)
: base(options, logger, encoder)
{
_bindingUser = bindingUser;
}
}Once created, register the services in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// 1. BSP Services
builder.Services.AddSessionProvider(config => { ... });
builder.Services.AddBindingService<User>();
// 2. Authentication Services
builder.Services.AddScoped<MyAuthStateProvider>();
builder.Services.AddAuthentication(AuthIdentifiers.AuthenticationScheme)
.AddScheme<AuthenticationSchemeOptions, MyAuthHandler>(AuthIdentifiers.AuthenticationScheme, null);
builder.Services.AddScoped<AuthenticationStateProvider>(sp
=> sp.GetRequiredService<MyAuthStateProvider>());
builder.Services.AddAuthorizationCore();
...Important
For more information on BSP configuration, visit Installation and Configuration.
Create a class that integrates with your database to validate the user trying to log into your application.
Since User inherits SessionBinder, it cannot be instantiated outside the binding service. You must therefore create an object that represents the user’s values retrieved from the database:
public record UserData(string id, string name, string email, List<string> roles);This record will later be used to populate the User instance in the service. Make sure each property matches the user object properties.
public class UserDataBaseService
{
public async Task<UserData?> IsValidUserAsync(string user, string pass)
{
var userData = await GetUser(user, pass);
return userData;
}
...
}This method validates the user’s credentials in the database and retrieves their data. If the user does not exist or credentials are invalid, it returns null.
Register this service in Program.cs and inject it into your custom AuthenticationStateProvider:
// Program.cs
...
builder.Services.AddAuthorizationCore();
// 3. Database Services
builder.Services.AddScoped<UserDataBaseService>();
...public class MyAuthStateProvider : AuthenticationStateProvider
{
...
private readonly UserDataBaseService _userService;
public MyAuthStateProvider(
SessionBindingService<User> bindingUser,
ISessionProvider provider,
UserDataBaseService userService)
{
...
_userService = userService;
}
}Before implementing the login method, allow your user class to receive values from the record:
public class User : SessionBinder
{
...
public void SetValues(UserData data)
{
IdUser = data.id;
UserName = data.name;
Email = data.email;
Roles = data.roles;
}
}In your custom AuthenticationStateProvider, add the LoginAsync function, which calls IsValidUserAsync and creates the ClaimsPrincipal. If the user is null, create an empty one:
public class MyAuthStateProvider : AuthenticationStateProvider
{
...
public async Task<bool> LoginAsync(string username, string password)
{
var userRecord = await _userService.IsValidUserAsync(username, password);
if (userRecord is null)
{
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(new ClaimsPrincipal()));
return false;
}
// First init the session
await _provider.CreateNewSession();
// And then, init the binding
var newUser = await _userBinder.InitializeBindedAsync();
newUser.SetValues(userRecord);
NotifyAuthenticationStateChanged(
Task.FromResult(new AuthenticationState(
newUser.ToClaimsPrincipal())));
return true;
}
}This method validates the user and password, and if both are correct:
- Creates a new session space in the application (setting the session ID cookie).
- Creates the user object and binds it to that session.
- Assigns the database values to the object (which stores them in the session).
- Converts the user into a
ClaimsPrincipal.
When a user refreshes or revisits the application, it is important to preserve authentication if the user is still logged in.
Override GetAuthenticationStateAsync in your custom provider so it creates a ClaimsPrincipal if a session already exists. Otherwise, return an empty principal:
public class MyAuthStateProvider : AuthenticationStateProvider
{
...
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var principal = new ClaimsPrincipal();
var getUser = await _userBinder.InitializeBindedAsync();
if (getUser.IsBindingInitialized())
principal = getUser.ToClaimsPrincipal();
return new(principal);
}
}Notice the call to getUser.IsBindingInitialized().
This method returns:
-
truewhen the user object is properly bound to a session -
falsewhen it is not initialized, or no session exists
You will do something similar in your AuthenticationHandler, overriding HandleAuthenticateAsync to validate the authenticated session.
Also override HandleChallengeAsync to handle user challenges when the session does not exist:
public class MyAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
...
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var userSession = await _bindingUser.InitializeBindedAsync();
if (!userSession.IsBindingInitialized())
return AuthenticateResult.NoResult();
var ticket = new AuthenticationTicket(userSession.ToClaimsPrincipal(), AuthIdentifiers.AuthenticationScheme);
return AuthenticateResult.Success(ticket);
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
var redirectUri = BuildRedirectUri("/login");
Response.Redirect(redirectUri);
return Task.CompletedTask;
}
}User information is connected to the session through SessionBindingService.
Simply inject the service in the component where you need the user information:
@page "/home"
@inject SessionBindingService<User> _binding
...
@code
{
...
void GetName()
{
var user = await _binding.InitializeBindedAsync();
var name = user.UserName;
// DO something...
}
}To end the user session, simply remove the current session and notify the app with an empty ClaimsPrincipal:
public class MyAuthStateProvider : AuthenticationStateProvider
{
...
public async Task LogoutAsync()
{
await _provider.RemoveSession();
NotifyAuthenticationStateChanged(
Task.FromResult(new AuthenticationState(new())));
}
}This removes the user’s current session.
The previous steps manage user sessions with regular cookies.
To configure HttpOnly cookies, you will need a few additional steps involving HTTP requests and a login Controller.
Ensure BSP is configured to use HttpOnly cookies:
// Program.cs
...
var builder = WebApplication.CreateBuilder(args);
// 1. BSP Services
builder.Services.AddSessionProvider(config =>
{
...
config.SyncProviderOnStart = true;
config.UseHttpOnlyCookies = true;
});
...Create a class that inherits ControllerBase to handle the HTTP request that registers the user’s login:
public class LoginDto
{
public string UserName { get; set; }
public string Password { get; set; }
}
[Route("api/login")]
public class LoginController : ControllerBase
{
private readonly MyAuthStateProvider _stateProvider;
private readonly IAntiforgery _antiforgery;
public LoginController(MyAuthStateProvider stateProvider, IAntiforgery antiforgery)
{
_stateProvider = stateProvider;
_antiforgery = antiforgery;
}
[HttpPost("create")]
public async Task<IActionResult> CreateSession([FromBody] LoginDto login)
{
try
{
await _antiforgery.ValidateRequestAsync(HttpContext);
}
catch (AntiforgeryValidationException ex)
{
return Forbid(); // 403 if the token is invalid
}
var ok = await _stateProvider.LoginAsync(login.UserName, login.Password);
return ok ? Ok(new { message = "Session created!" })
: BadRequest(new { message = "Invalid user or password" });
}
}Add this line to Program.cs to enable controllers:
// Program.cs
...
builder.Services.AddControllers();This controller calls your AuthenticationStateProvider login method with the credentials in the request body.
It also ensures the antiforgery token matches the one expected by the HttpOnly cookie mechanism provided by Blazor.
To call CreateSession via an HTTP POST, you must do so through JavaScript inside your form:
// login.js
async function login(UserName, Password, token)
{
var response = await fetch('/api/login/create', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': token
},
body: JSON.stringify({ UserName, Password })
});
return response.ok;
}Important
Make sure to add the script at the end of the body inApp.razor.
Do not useHttpClientfor thePOSTrequest, since the cookie created by BSP will not be stored.
In your Blazor login form, obtain and store the antiforgery token:
@page "/login"
@attribute [AllowAnonymous]
@inject IAntiforgery _antiforgery
@inject IHttpContextAccessor _httpConext
@inject IJSRuntime _jsRuntime
@inject NavigationManager _navManager
// Your form here...
@code
{
string requestToken = "";
protected override async Task OnInitializedAsync()
{
requestToken = _antiforgery.GetTokens(_httpConext.HttpContext!).RequestToken!;
}
}Call your JavaScript login method inside your login handler:
async void OnLogin(LoginArgs args)
{
if (await _jsRuntime.InvokeAsync<bool>("login", args.Username, args.Password, requestToken))
_navManager.NavigateTo("/home", true);
}This validates the form POST request.
If you attempt to send the POST from tools like Postman, the validation will fail.
Now you will notice that the cookie storing the session ID is now HttpOnly.
To log out, simply call the LogoutAsync method from the custom AuthenticationStateProvider when initializing a page:
@page "/logout"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject NavigationManager Navigation
@inject MyAuthStateProvider Auth
<p>Redirecting...</p>
@code
{
protected override async Task OnInitializedAsync()
{
await Auth.LogoutAsync();
Navigation.NavigateTo("/login");
}
}This destroys the user’s current session and removes the HttpOnly cookie.
Made with ❤️ by Oscar D. Soto