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.Web
andYourApp.Mobile
, add a reference toYourApp.Shared
.
- In
- Set Up the Projects:
- Ensure all projects target .NET 9.
- Update the
.csproj
files 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
/login
and 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
.Shared
project 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.