I am writing my first Blazor server-side app (.NET 8) and I am struggling with the authentication logic: when injected into a service, the user/auth information is empty.
I have a simple service to hold the user info once logged in:
public class UserService{ private string _username = string.Empty; public string Username => _username; public bool IsAuthenticated => !string.IsNullOrEmpty(_username); public void SetUser(string username) { _username = username; } public void ClearUser() { _username = ""; }}When it gets injected into a service, Username is always empty:
public class TestService{ private string Username { get; set; } public TestService(UserService userService) { Username = userService.Username; <-- Username is empty here. }}Below is the rest of the related source.
Login (razor) page (Login.cshtml.cs):
public class LoginModel : PageModel { private readonly LoginService _loginService; private readonly CustomAuthenticationStateProvider _authenticationStateProvider; [BindProperty] public string Username { get; set; } [BindProperty] public string Password { get; set; } public string ErrorMessage { get; set; } public string ReturnUrl { get; set; } = "/"; public LoginModel(LoginService loginService, CustomAuthenticationStateProvider authenticationStateProvider) { _loginService = loginService; _authenticationStateProvider = authenticationStateProvider; } public void OnGet(string? returnUrl = "/") { ReturnUrl = returnUrl ?? "/"; } public async Task<IActionResult> OnPostAsync(string returnUrl = "/") { if (_loginService.CheckCredentials(Username, Password)) { var claims = new List<Claim> { new Claim(ClaimTypes.Name, Username) }; var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal); _authenticationStateProvider.NotifyUserAuthentication(Username); <-- Username is set here. if (Url.IsLocalUrl(returnUrl)) return Redirect(returnUrl); return Redirect("/"); } else { ErrorMessage = "Invalid username or password"; return Page(); } } }Custom authentication state provider:
public class CustomAuthenticationStateProvider : AuthenticationStateProvider{ private readonly UserService _userService; private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity()); public CustomAuthenticationStateProvider(UserService userService) { _userService = userService; } public override Task<AuthenticationState> GetAuthenticationStateAsync() { if (!_userService.IsAuthenticated) { return Task.FromResult(new AuthenticationState(_anonymous)); } var claims = new[] { new Claim(ClaimTypes.Name, _userService.Username) }; <-- _userService.Username is set here. var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var user = new ClaimsPrincipal(identity); return Task.FromResult(new AuthenticationState(user)); } public void NotifyUserAuthentication(string username) { _userService.SetUser(username); <-- username is set here. NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } public void NotifyUserLogout() { _userService.ClearUser(); NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); }}Program.cs (a bit trimmed down):
var builder = WebApplication.CreateBuilder(args);builder.Services.AddRazorComponents() .AddInteractiveServerComponents();builder.Services.AddDevExpressBlazor(options => { options.BootstrapVersion = DevExpress.Blazor.BootstrapVersion.v5; options.SizeMode = DevExpress.Blazor.SizeMode.Medium;});builder.Services.AddMvc();builder.Services.AddRazorPages();builder.Services.AddScoped<UserService>();builder.Services.AddScoped<LoginService>();builder.Services.AddScoped<TestService>();builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();builder.Services.AddBlazoredSessionStorage();builder.Services.AddDistributedMemoryCache();builder.Services.AddSession(options =>{ options.IdleTimeout = TimeSpan.FromMinutes(30); options.Cookie.HttpOnly = true; options.Cookie.IsEssential = true;});builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/Login"; // options.ExpireTimeSpan = TimeSpan.FromMinutes(30); options.SlidingExpiration = true; options.Cookie.IsEssential = true; });builder.Services.AddScoped<CustomAuthenticationStateProvider>();builder.Services.AddScoped<AuthenticationStateProvider>(provider => provider.GetRequiredService<CustomAuthenticationStateProvider>());builder.Services.AddAuthorizationCore();var app = builder.Build();app.UseSession();app.UseHttpsRedirection();app.UseStaticFiles(); ;app.UseRouting();app.UseAuthentication();app.UseAuthorization();app.UseAntiforgery();app.MapControllers();app.MapRazorPages();app.MapRazorComponents<App>().AddInteractiveServerRenderMode();app.Run();When the user logs in, the username is passed properly to NotifyUserAuthentication in CustomAuthenticationStateProvider (which calls _userService.SetUser) and in GetAuthenticationStateAsync.
It is only in the Constructor of the service that the username is empty.
I am using VS2022 and the app will also run under IIS in production later.
What am I missing?
Update 10/15/2024:I have uploaded the minimal reproducible example on Google Drive. Feel free to download it to reproduce the issue:Runnable MRE