In most of our applications, we want to restrict access and we want to provide a user-specific experience. Usually, we have a Single Page Application (SPA) and a REST API. We use OpenID Connect to authenticate users and JSON Web Tokens (JWTs) to access the API. As of today, Blazor WebAssembly project templates do not support authentication scenarios. Microsoft plans to add authentication support with the next release in March (Update March 10, 2020: Blazor WebAssembly Preview 2 has now support for token-based authentication). For now, we can use one of the available open-source projects (like Blazor.Msal) or a custom implementation.
The lack of official support has caused me to think about other options. ASP.NET Core has already good support for OpenID Connect. It would be nice to re-use the existing features and do all the OpenID Connect related stuff on the server instead of in the Blazor application on the client. Then I reminded that in January 2019, Dominick Baier (known for his work on IdentityServer) published an article about "An alternative way to secure SPAs (with ASP.NET Core, OpenID Connect, OAuth 2.0 and ProxyKit)".
To get all the details and security aspects of this approach, please read the linked blog post. In this post, I will show how to use it together with a Blazor WebAssembly application. You can find the full sample code on GitHub.
Structure of the sample
These are the parts that are used in this sample:
- Identity Server:
Issues the security tokens.
I am using the demo server hosted at https://demo.identityserver.io/ - Web API:
It has two endpoints to provide sample weather forecast data. One is available anonymously and one requires authentication.
https://localhost:5101 - Backend-for-Frontend (BFF):
Hosts the Blazor client, handles the OIDC flow and forwards API calls.
https://localhost:5001 - Blazor Client:
Runs as part of the BFF, so it has the same URL.
The API
The API is pretty simple and has only one controller with two actions. Both actions return a list of weather forecast data with random values. The difference is that one action is available without authentication and the other action requires a valid access token.
In the Startup.cs file, we configure the authentication middleware for JSON web tokens issued by the identity server.
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => { options.Authority = "https://demo.identityserver.io/"; options.Audience = "api"; options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.Name, RoleClaimType = JwtClaimTypes.Role, }; });
Besides, I configured Swagger and Swagger UI to test the API and authentication without a frontend. You can go to https://localhost:5101/swagger to see the Swagger UI. Swagger is optional, and the sample works also without it.
The Backend-for-Frontend
This project has three tasks. The first is to serve the Blazor client application and all the static files. The second task is to handle the authentication process. This includes the OpenID Connect flow, storing the token in an auth cookie, refreshing tokens, and to provide user-information to the Blazor client application. And the third task is to forward the calls to the API and attach the access token from the cookie. In this blog post, I will focus only on the last two tasks.
Authentication
In the Startup.cs of the host, we have to configure the authentication middleware to use OpenID Connect to get an access token and store it in a cookie.
public void ConfigureServices(IServiceCollection services) { //... JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); services.AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { options.Cookie.SameSite = SameSiteMode.Strict; options.Events.OnSigningOut = async e => { await e.HttpContext.RevokeUserRefreshTokenAsync(); }; }) .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { options.Authority = "https://demo.identityserver.io/"; // to test token refresh, we use 'interactive.confidential.short' -> token life time is 75 seconds options.ClientId = "interactive.confidential.short"; options.ClientSecret = "secret"; options.ResponseType = OpenIdConnectResponseType.Code; options.Scope.Add("api"); options.Scope.Add("offline_access"); options.SaveTokens = true; options.UsePkce = true; options.GetClaimsFromUserInfoEndpoint = true; options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.Name, RoleClaimType = JwtClaimTypes.Role, }; }); //... }
We use the Authorization Code flow with PKCE. And SameSite cookies.
For token management, especially for refreshing the access token, I use the IdentityModel.AspNetCore library. To register the library we have to add only one line of code: services.AddAccessTokenManagement();
. No more configuration is needed, as it takes the parameters from the authentication configuration.
And then we have two controllers. The first one, the Account controller, has two actions. The Login action challenges the OpenID Connect flow, and the Logout action deletes the authentication cookie and signs the user out from the identity server.
[Route("[controller]")] public class AccountController : ControllerBase { [HttpGet("Login")] public ActionResult Login(string returnUrl) { return Challenge(new AuthenticationProperties { RedirectUri = !string.IsNullOrEmpty(returnUrl) ? returnUrl : "/" }); } [HttpGet("Logout")] public IActionResult Logout() => SignOut( new AuthenticationProperties { RedirectUri = "/" }, CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme); } [/code] The User controller has only one action that returns information of the current user. We need it because the Blazor client application has no access to the authentication cookie. Instead, we will call this endpoint to check if the user is authenticated and get information like the username. [Route("[controller]")] [ApiController] public class UserController : ControllerBase { [HttpGet] [Authorize] [AllowAnonymous] public IActionResult GetCurrentUser() => Ok(User.Identity.IsAuthenticated ? CreateUserInfo(User) : UserInfo.Anonymous); private UserInfo CreateUserInfo(ClaimsPrincipal claimsPrincipal) { if (!claimsPrincipal.Identity.IsAuthenticated) { return UserInfo.Anonymous; } var userInfo = new UserInfo { IsAuthenticated = true }; if (claimsPrincipal.Identity is ClaimsIdentity claimsIdentity) { userInfo.NameClaimType = claimsIdentity.NameClaimType; userInfo.RoleClaimType = claimsIdentity.RoleClaimType; } else { userInfo.NameClaimType = JwtClaimTypes.Name; userInfo.RoleClaimType = JwtClaimTypes.Role; } if (claimsPrincipal.Claims.Any()) { var claims = new List<ClaimValue>(); var nameClaims = claimsPrincipal.FindAll(userInfo.NameClaimType); foreach (var claim in nameClaims) { claims.Add(new ClaimValue(userInfo.NameClaimType, claim.Value)); } userInfo.Claims = claims; } return userInfo; } }
API Proxy
To forward the calls to the API we use the open-source project ProxyKit. The project describes itself as a toolkit to create code-first HTTP Reverse Proxies hosted in ASP.NET Core as middleware.
To use it for our use-case we have to add only a few lines of code. First, we register the proxy service:
public void ConfigureServices(IServiceCollection services) { //... services.AddProxy((clientBuilder) => { // adds the access token to all forwarded requests, // and refreshes the access token if needed. clientBuilder.AddUserAccessTokenHandler(); }); }
The AddProxy method allows us to configure that HttpClient that is used for all forwarded requests. The AddUserAccessTokenHandler extension is part of the IdentityModel.AspNetCore library. It adds the authentication token to all requests sent by this HttpClient. And it automatically refreshes the access token if needed.
And the second step is to configure the middleware to forward all request starting with "/api" to the REST API:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { //... app.Map("/api", api => { api.RunProxy(async context => { var forwardContext = context.ForwardTo("https://localhost:5101"); return await forwardContext.Send(); }); }); //... }
<h2>The Blazor Client Application</h2>
For the client application, I took the default Blazor WebAssembly template and made only a few modifications.
To enable authentication, we must add the CascadingAuthenticationState to the App.razor file.
<CascadingAuthenticationState> <Router AppAssembly="@typeof(Program).Assembly"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> </Found> <NotFound> <LayoutView Layout="@typeof(MainLayout)"> Sorry, there's nothing at this address. </LayoutView> </NotFound> </Router> </CascadingAuthenticationState>
Then I created a copy of the FetchData.razor file and named it FetchProtectedData.razor. In this file, I added the [Authorize] attribute to make sure only authenticated users can access this page. And I changed the URL to load the weather data from the protected API endpoint.
In the Index.razor page I added a login button that is shown if the user is not authenticated. And if the user is authenticated, the name of the user and a log out button is shown.
<AuthorizeView> <Authorized> <strong>Hello, @context.User.Identity.Name!</strong> <a href="Account/Logout">Log out</a> </Authorized> <NotAuthorized> <a href="Account/Login">Log in</a> </NotAuthorized> </AuthorizeView>
But how does the Blazor app know if the user is authenticated? The SPA has no access to the authentication cookie. That’s why we have to call the User endpoint of the BFF to find out if the user is authenticated.
To make this work we register a custom AuthenticationStateProvider. I named mine HostAuthenticationStateProvider.
public class HostAuthenticationStateProvider : AuthenticationStateProvider { private static readonly TimeSpan _userCacheRefreshInterval = TimeSpan.FromSeconds(60); private readonly NavigationManager _navigation; private readonly HttpClient _client; private readonly ILogger<HostAuthenticationStateProvider> _logger; private DateTimeOffset _userLastCheck = DateTimeOffset.FromUnixTimeSeconds(0); private ClaimsPrincipal _cachedUser = new ClaimsPrincipal(new ClaimsIdentity()); public HostAuthenticationStateProvider(NavigationManager navigation, HttpClient client, ILogger<HostAuthenticationStateProvider> logger) { _navigation = navigation; _client = client; _logger = logger; } public override async Task<AuthenticationState> GetAuthenticationStateAsync() => new AuthenticationState(await GetUser(useCache: true)); private async ValueTask<ClaimsPrincipal> GetUser(bool useCache = false) { var now = DateTimeOffset.Now; if (useCache && now < _userLastCheck + _userCacheRefreshInterval) { return _cachedUser; } _cachedUser = await FetchUser(); _userLastCheck = now; return _cachedUser; } private async Task<ClaimsPrincipal> FetchUser() { UserInfo user = null; try { user = await _client.GetJsonAsync<UserInfo>("User"); } catch (Exception exc) { _logger.LogWarning(exc, "Fetching user failed."); } if (user == null || !user.IsAuthenticated) { return new ClaimsPrincipal(new ClaimsIdentity()); } var identity = new ClaimsIdentity( nameof(HostAuthenticationStateProvider), user.NameClaimType, user.RoleClaimType); if (user.Claims != null) { foreach (var claim in user.Claims) { identity.AddClaim(new Claim(claim.Type, claim.Value)); } } return new ClaimsPrincipal(identity); } }
What we are doing here is that we are overriding the GetAuthenticationStateAsync method. If the method gets called the first time, our cache is empty and we have to send a request to the host. If the user is authenticated, we create a ClaimsIdentity with all the claims of the user. Otherwise, we will create an empty, unauthenticated, ClaimsIdentity.
The last step is to register the provider in the Program.cs file:
public static async Task Main(string[] args) { //... builder.Services.TryAddSingleton<AuthenticationStateProvider, HostAuthenticationStateProvider>(); // ... await builder.Build().RunAsync(); }
That’s it, now you can log in and access the protected page and load weather data from the protected API endpoint.
Bonus
The AuthenticationStateProvider.GetAuthenticationStateAsync gets called only when you navigate between razor pages. But a user may stay on the same page for some time. If the auth cookie or the token is not valid anymore (user logged out in another tab or the token was revoked) the API will return HTTP 401.
In the sample, I added a DelegatingHandler that handles 401 errors globally. In case the user is not authenticated it redirects the user to the login page.
public class AuthorizedHandler : DelegatingHandler { private readonly HostAuthenticationStateProvider _authenticationStateProvider; public AuthorizedHandler(HostAuthenticationStateProvider authenticationStateProvider) { _authenticationStateProvider = authenticationStateProvider; } protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { var authState = await _authenticationStateProvider.GetAuthenticationStateAsync(); HttpResponseMessage responseMessage; if (!authState.User.Identity.IsAuthenticated) { // if user is not authenticated, immediately set response status to 401 Unauthorized responseMessage = new HttpResponseMessage(HttpStatusCode.Unauthorized); } else { responseMessage = await base.SendAsync(request, cancellationToken); } if (responseMessage.StatusCode == HttpStatusCode.Unauthorized) { // if server returned 401 Unauthorized, redirect to login page _authenticationStateProvider.SignIn(); } return responseMessage; } }
Conclusion
As we can see it’s easy to add token-based authentication to a Blazor application by re-using the existing server-side authentication middleware. No need to implement the authentication flow or a token refresh logic on the client-side.
You can find the full sample code on GitHub.