Implementing Authentication in a .NET 9 MAUI Blazor Hybrid App (SSR)
This guide provides detailed steps to implement a basic username and password authentication system in a .NET 9 MAUI Blazor Hybrid app using the new Blazor Web App template with a .Shared project. The authentication system accounts for the limitations of server-side rendering (SSR) in Blazor Server applications and uses platform-specific authentication services.
Table of Contents
- Project Structure Overview
- Prerequisites
- Step-by-Step Implementation
- Additional Considerations
- Conclusion
Project Structure Overview
Your solution will consist of three projects:
YourApp.Web: The Blazor Server (Web) application.YourApp.Mobile: The MAUI Blazor Hybrid (Mobile) application.YourApp.Shared: Contains shared components, pages, and services.
Prerequisites
- .NET 9 SDK: Ensure that you have the .NET 9 SDK installed.
- Visual Studio 2022 (17.12 or later): For project creation and development.
- MAUI Workload: Install the MAUI workload for cross-platform development.
- Install the following Nuget Packages
-
- In the .SharedProject
- Microsoft.AspNetCore.Authorization (9.0.0)
- Microsoft.AspNetCore.Components.Authorization (9.0.0)
- Microsoft.AspNetCore.Components.Web (9.0.0)
- Microsoft.Maui.Essentials (9.0.10)
- In the .SharedProject
-
Step-by-Step Implementation
Create a New Solution
- Open Visual Studio 2022.
- Create a New Project:
- Template: Search for Blazor Web App.
- Project Name: Enter
YourApp.Web. - Solution Name: Enter
YourApp.
- Add a MAUI Blazor App:
- Right-click on the solution in Solution Explorer.
- Select Add > New Project.
- Choose .NET MAUI Blazor App.
- Project Name: Enter
YourApp.Mobile.
- Add a Class Library for Shared Code:
- Right-click on the solution.
- Select Add > New Project.
- Choose Class Library.
- Project Name: Enter
YourApp.Shared.
- Add References:
- In
YourApp.WebandYourApp.Mobile, add a reference toYourApp.Shared.
- In
- Set Up the Projects:
- Ensure all projects target .NET 9.
- Update the
.csprojfiles if necessary.
Add some global usings for razor pages
In YourApp.Shared, open and edit _Imports.razor and add the following usings:
@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.JSInterop @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization; @using YourApp.Shared.Layout @using YourApp.Shared.Pages @using YourApp.Shared.Services
Implement Platform-Specific Authentication Services
Due to the limitations of server-side rendering (SSR) in Blazor Server applications, we’ll implement platform-specific authentication services to handle authentication differently in the web and mobile applications.
Create an Interface for Authentication
In YourApp.Shared/Services, create ICustomAuthenticationService.cs:
// YourApp.Shared/Services/ICustomAuthenticationService.cs
using System.Security.Claims;
using System.Threading.Tasks;
namespace YourApp.Shared.Services
{
public interface ICustomAuthenticationService
{
Task SignInAsync(string username);
Task SignOutAsync();
Task<ClaimsPrincipal> GetCurrentUserAsync();
}
}
Implement the Authentication Service for Blazor Server
In YourApp.Web/Services, create ServerAuthenticationService.cs:
// YourApp.Web/Services/ServerAuthenticationService.cs
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using YourApp.Shared.Services;
namespace YourApp.Web.Services
{
public class ServerAuthenticationService : ICustomAuthenticationService
{
private readonly IHttpContextAccessor _httpContextAccessor;
public ServerAuthenticationService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public async Task SignInAsync(string username)
{
var claims = new[] { new Claim(ClaimTypes.Name, username) };
var identity = new ClaimsIdentity(claims, "Cookies");
var principal = new ClaimsPrincipal(identity);
await _httpContextAccessor.HttpContext.SignInAsync("Cookies", principal);
}
public async Task SignOutAsync()
{
await _httpContextAccessor.HttpContext.SignOutAsync("Cookies");
}
public Task<ClaimsPrincipal> GetCurrentUserAsync()
{
var user = _httpContextAccessor.HttpContext.User;
return Task.FromResult(user);
}
}
}
Update Program.cs in the Web Project
Configure authentication services in YourApp.Web/Program.cs:
// YourApp.Web/Program.cs
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
using YourApp.Shared.Services;
using YourApp.Web.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// Configure cookie authentication
builder.Services.AddAuthentication("Cookies")
.AddCookie("Cookies", options =>
{
options.LoginPath = "/login";
});
builder.Services.AddAuthorizationCore();
// Register IHttpContextAccessor
builder.Services.AddHttpContextAccessor();
// Register the server-specific authentication service
builder.Services.AddScoped<ICustomAuthenticationService, ServerAuthenticationService>();
// Register the custom AuthenticationStateProvider
builder.Services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
// Register UserService
builder.Services.AddScoped<UserService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.Run();
Create ServerAuthenticationStateProvider
In YourApp.Web/Services, create ServerAuthenticationStateProvider.cs:
// YourApp.Web/Services/ServerAuthenticationStateProvider.cs
using Microsoft.AspNetCore.Components.Authorization;
using System.Security.Claims;
using System.Threading.Tasks;
using YourApp.Shared.Services;
namespace YourApp.Web.Services
{
public class ServerAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly ICustomAuthenticationService _authenticationService;
public ServerAuthenticationStateProvider(ICustomAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var user = await _authenticationService.GetCurrentUserAsync();
return new AuthenticationState(user);
}
}
}
Implement the Authentication Service for MAUI
In YourApp.Shared/Services, create CustomAuthenticationService.cs:
// YourApp.Shared/Services/CustomAuthenticationService.cs
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Authorization;
namespace YourApp.Shared.Services
{
public class CustomAuthenticationService : ICustomAuthenticationService
{
private readonly CustomAuthenticationStateProvider _authStateProvider;
public CustomAuthenticationService(CustomAuthenticationStateProvider authStateProvider)
{
_authStateProvider = authStateProvider;
}
public async Task SignInAsync(string username)
{
await _authStateProvider.SignInAsync(username);
}
public async Task SignOutAsync()
{
await _authStateProvider.SignOutAsync();
}
public async Task<ClaimsPrincipal> GetCurrentUserAsync()
{
var authState = await _authStateProvider.GetAuthenticationStateAsync();
return authState.User;
}
}
}
Adjust CustomAuthenticationStateProvider for MAUI
In YourApp.Shared/Services, update CustomAuthenticationStateProvider.cs:
// YourApp.Shared/Services/CustomAuthenticationStateProvider.cs
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Maui.Storage;
namespace YourApp.Shared.Services
{
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
{
private const string AuthDataKey = "authData";
private ClaimsPrincipal _currentUser;
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_currentUser != null)
{
return new AuthenticationState(_currentUser);
}
string username = await SecureStorage.Default.GetAsync(AuthDataKey);
if (!string.IsNullOrWhiteSpace(username))
{
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username)
}, "apiauth_type");
_currentUser = new ClaimsPrincipal(identity);
}
else
{
_currentUser = new ClaimsPrincipal(new ClaimsIdentity());
}
return new AuthenticationState(_currentUser);
}
public async Task SignInAsync(string username)
{
await SecureStorage.Default.SetAsync(AuthDataKey, username);
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username)
}, "apiauth_type");
_currentUser = new ClaimsPrincipal(identity);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public async Task SignOutAsync()
{
SecureStorage.Default.Remove(AuthDataKey);
_currentUser = new ClaimsPrincipal(new ClaimsIdentity());
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
}
Register Services in the MAUI Project
In YourApp.Mobile/MauiProgram.cs, register the authentication services:
// YourApp.Mobile/MauiProgram.cs
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using YourApp.Shared.Services;
namespace YourApp.Mobile
{
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddMauiBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
builder.Services.AddAuthorizationCore();
// Register the custom authentication services
builder.Services.AddScoped<CustomAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(provider =>
provider.GetRequiredService<CustomAuthenticationStateProvider>());
builder.Services.AddScoped<ICustomAuthenticationService, CustomAuthenticationService>();
// Register UserService
builder.Services.AddScoped<UserService>();
return builder.Build();
}
}
}
Implement a Simple User Store
Create UserService.cs in YourApp.Shared/Services:
// YourApp.Shared/Services/UserService.cs
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace YourApp.Shared.Services
{
public class UserService
{
private List<UserCredential> _users = new List<UserCredential>
{
new UserCredential { Username = "user1", Password = "password1", Role = "User" },
new UserCredential { Username = "admin", Password = "admin123", Role = "Admin" }
};
public Task<UserCredential> AuthenticateUserAsync(string username, string password)
{
var user = _users.FirstOrDefault(u => u.Username == username && u.Password == password);
return Task.FromResult(user);
}
}
public class UserCredential
{
public string Username { get; set; }
public string Password { get; set; }
public string Role { get; set; }
}
}
Build Login and Logout Components
Create Login.razor
In YourApp.Shared/Pages, create Login.razor:
@page "/login"
@using YourApp.Shared.Services
@inject ICustomAuthenticationService AuthService
@inject UserService UserService
@inject NavigationManager NavigationManager
<h3>Login</h3>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger">@ErrorMessage</div>
}
<EditForm Model="loginModel" OnValidSubmit="HandleLogin">
<DataAnnotationsValidator />
<ValidationSummary />
<div>
<label>Username:</label>
<InputText @bind-Value="loginModel.Username" />
</div>
<div>
<label>Password:</label>
<InputText type="password" @bind-Value="loginModel.Password" />
</div>
<button type="submit">Login</button>
</EditForm>
@code {
private LoginModel loginModel = new LoginModel();
private string ErrorMessage;
private async Task HandleLogin()
{
var user = await UserService.AuthenticateUserAsync(loginModel.Username, loginModel.Password);
if (user != null)
{
await AuthService.SignInAsync(user.Username);
NavigationManager.NavigateTo("/");
}
else
{
ErrorMessage = "Invalid username or password.";
}
}
public class LoginModel
{
public string Username { get; set; }
public string Password { get; set; }
}
}
Create Logout.razor
In YourApp.Shared/Pages, create Logout.razor:
@page "/logout"
@using YourApp.Shared.Services
@inject ICustomAuthenticationService AuthService
@inject NavigationManager NavigationManager
<h3>Logging out...</h3>
@code {
protected override async Task OnInitializedAsync()
{
await AuthService.SignOutAsync();
NavigationManager.NavigateTo("/");
}
}
Protect Pages with Authorization
Create a Protected Page
Create ProtectedPage.razor in YourApp.Shared/Pages:
@page "/protected"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject ICustomAuthenticationService AuthService
<h3>Protected Page</h3>
<p>Welcome, @userName!</p>
@code {
private string userName;
protected override async Task OnInitializedAsync()
{
var user = await AuthService.GetCurrentUserAsync();
userName = user.Identity?.Name;
}
}
Update the Navigation Menu
In YourApp.Shared/Shared, update NavMenu.razor:
@inject AuthenticationStateProvider AuthenticationStateProvider
@implements IDisposable
<nav>
<ul>
<li><NavLink href="/">Home</NavLink></li>
@if (isAuthenticated)
{
<li><NavLink href="protected">Protected Page</NavLink></li>
<li><NavLink href="logout">Logout (@userName)</NavLink></li>
}
else
{
<li><NavLink href="login">Login</NavLink></li>
}
</ul>
</nav>
@code {
private bool isAuthenticated;
private string userName;
private AuthenticationState authenticationState;
protected override async Task OnInitializedAsync()
{
authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
UpdateAuthenticationState(authenticationState);
AuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
}
private void UpdateAuthenticationState(AuthenticationState authState)
{
var user = authState.User;
isAuthenticated = user.Identity?.IsAuthenticated ?? false;
userName = user.Identity?.Name;
StateHasChanged();
}
private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
{
InvokeAsync(async () =>
{
var authState = await task;
UpdateAuthenticationState(authState);
});
}
public void Dispose()
{
AuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
}
}
Adjust Routing in Routes.razor
In YourApp.Shared, update Routes.razor:
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Routing
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Routes).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (context.User.Identity.IsAuthenticated)
{
<p>You are not authorized to access this resource.</p>
}
else
{
<p>You are not authenticated. Please <a href="/login">log in</a>.</p>
}
</NotAuthorized>
<Authorizing>
<p>Authorizing...</p>
</Authorizing>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState>
@code {
[CascadingParameter]
private Task<AuthenticationState> authenticationStateTask { get; set; }
private AuthenticationState context => authenticationStateTask.Result;
}
Test the Application
Run the Web App
- Start the Web Project (
YourApp.Web). - Navigate to a Protected Page: Visit
/protected. - Verify Redirection: You should see a message indicating you are not authenticated.
- Log In: Navigate to
/loginand useuser1/password1. - Access Protected Content: You should now see the protected page.
Run the Mobile App
- Start the Mobile Project (
YourApp.Mobile). - Repeat the Steps: Perform the same steps as above to verify authentication on mobile.
Additional Considerations
Persisting Authentication State
In the MAUI app, authentication state is persisted using SecureStorage. In the Blazor Server app, authentication state is managed via cookies using ASP.NET Core’s authentication mechanisms.
Enhancing Security
- Password Hashing: Implement hashing for passwords instead of storing them in plain text.
- Use HTTPS: Ensure your web application uses HTTPS to encrypt data in transit.
- Input Validation: Validate all user inputs to prevent injection attacks.
- External Authentication Providers: Consider integrating with OAuth or OpenID Connect for robust authentication.
Conclusion
By following these steps, you’ve successfully implemented a basic authentication system in your .NET 9 MAUI Blazor Hybrid app that accounts for the limitations of server-side rendering in Blazor Server applications. This setup allows you to:
- Understand Platform-Specific Authentication: Manage authentication appropriately in both web and mobile environments.
- Share Code Effectively: Use the
.Sharedproject to share components and services between web and mobile apps. - Prepare for Future Enhancements: Lay the groundwork for integrating more secure and complex authentication mechanisms in the future.
Next Steps:
- Implement Secure Authentication: Consider integrating OAuth or OpenID Connect for robust authentication.
- Role-Based Authorization: Utilize roles to manage user access to different parts of the application.
- UI/UX Improvements: Enhance the user interface for better usability and aesthetics.