BlazorServer OIDC Authentication
Blazor Authentication with OpenID Connect
Table of Contents
- Updating the Code
- Accessing the User Properties
- Update Startup
- OIDC Client Setup
- Anonymous Access
- Login and Logout
- Expiration Problem
- How else can we do this
- Lets make this easier for next time
- Conclusion
BlazorServer OIDC: login, logout, and anonymous access with IdentityServer
This article briefly covers how to get OIDC authorization working for a Blazor server-side web app. We’ll use IdentityServer4’s publicly-available demo server which allows anyone to perform an OIDC login, since the OIDC authority isn’t really important here (at LavelyIO, we use KeyCloak (Developed by RedHat!).
I had three goals for this article: login, logout, and some level of support for anonymous access (like a landing page) .
Get the Code
I have the code available on Github
A follow-up article is now available: Blazor Login Expiration with OpenID Connect. Note that the repository now reflects the changes from this new article. Generally they’re additions to the code shown in this article.
Updating the Code
I began with an off-the-shelf Blazor server-side app template without ASP.NET Core Identity support of any kind. It isn’t too important for our purposes, but the .net6 setup is a good place to start by itself.You know, for learning.
// App.razor
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
<h1>Sorry</h1>
<p>You're not authorized to reach this page.</p>
<p>You may need to log in as a different user.</p>
</NotAuthorized>
<Authorizing>
<h1>Authentication in progress</h1>
<p>We're almost there.</p>
</Authorizing>
</AuthorizeRouteView>
</Found>
<NotFound>
<CascadingAuthenticationState>
<LayoutView Layout="@typeof(MainLayout)">
<h1>Sorry</h1>
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</CascadingAuthenticationState>
</NotFound>
</Router>
Accessing the User Properties
In a Blazor server-side application, authenticated user information is available to Razor components by injecting the AuthenticationStateProvider. We’ll modify Index.razor breifly, so we can see some data on screen. It helps me to visualize it first.
@page "/"
@inject AuthenticationStateProvider AuthState
<h1>Hello, @Username</h1>
<p>Welcome to your new app.</p>
<p>
<a href="/Logout">Logout</a>
</p>
@code
{
private string Username = "Anonymous User";
protected override async Task OnInitializedAsync()
{
var state = await AuthState.GetAuthenticationStateAsync();
Username =
state.User.Claims
.Where(c => c.Type.Equals("name"))
.Select(c => c.Value)
.FirstOrDefault() ?? string.Empty;
await base.OnInitializedAsync();
}
}
I know what you're thinking, "but how do I ensure the user can’t reach this content unless they’re authenticated, so it’s safe to simply assume user information is available?". Unfortunately, the default OIDC settings don’t populate properties like User.Identity.Name, so we use a bit of LINQ to extract a claim, in this case, the name.
Update Startup
The OIDC configuration process in Startup.cs is pretty similar to a non-Blazor MVC application. First, add a NuGet package reference to Microsoft.AspNetCore.Authentication.OpenIdConnect. Add this to the end of the ConfigureServices method:
// Starup.cs
services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://demo.identityserver.io/";
options.ClientId = "interactive.confidential.short"; // 75 seconds
options.ClientSecret = "secret";
options.ResponseType = "code";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.Events = new OpenIdConnectEvents
{
OnAccessDenied = context =>
{
context.HandleResponse();
context.Response.Redirect("/");
return Task.CompletedTask;
}
};
});
The OIDC client information comes from the IdentityServer demo site, but this showcases the real-world pattern. This is basically plug-in-play for any other Identity Server Provider who supports OIDC (most of them). That particular client ID is configured for quick expiration (75 seconds) which is very useful for quick tests:
OIDC Client Setup
The OpenIdConnectEvents handler just redirects the user to the site root when an OIDC access_denied message is returned from the login server. Unfortunately, this message is also returned if the user clicks the Cancel button in the IdentityServer UI, which is why we’re handling it this way.
*Although, i'm not sure if OIDC itself doesn’t offer a good way to differentiate between user bail-out versus a failed login attempt.
After that, lets borrow more from the MVC Authorization Policy world:
**Update: The follow-up article Blazor Login Expiration with OpenID Connect explains why this addition is unnecessary, and in fact does nothing useful.
services.AddMvcCore(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
The RequireAuthenticatedUser policy locks down the entire site by default. This means you're not polluting youre codebase and cluttering things up. When an anonymous user accesses the site, they’re automatically and immediately redirected to the login server.
Well shoot, that interfered with two of my three goals: anonymous access and logout. *(Actually, logout is still possible, but the user is immediately redirected to login again. Loops for days! )
We have one more line of code to add down in the Configure method, after UseStaticFiles and UseRouting calls:
app.UseStaticFiles();
app.UseAuthentication(); // add this
app.UseRouting();
If you run the project now you’ll have full OIDC login support, although the "/Logout" link we added to Index.razor doesn’t do anything yet.
Anonymous Access
I figure the most common use-case for secured web applications is to have the entire site secured by default, with only a small area accessible anonymously. For public sites, this might be “About” pages and instructions for registering a new account, and in an internal enterprise line-of-business application, it could be instructions about how to request access to the system. Unfortunately, the RequireAuthenticatedUser policy blindly kicks off login for any anonymous user.
In the MVC world, there is a very useful AllowAnonymous attribute, but Blazor ignores it. However, we can put it to work in the _Hub.cshtml file because that’s a true Razor page. Then we conditionally emit the Blazor client-side hub content or content specific to anonymous users. We add this to the top of the file:
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
Then check User.Identity.IsAuthenticated within the <body> tag:
<body>
@if (User.Identity.IsAuthenticated)
{
<app>
<component type="typeof(App)" render-mode="ServerPrerendered" />
</app>
}
else
{
<h3>You are not logged into this application.</h3>
<p>
<a href="/Login">Login</a>
</p>
}
</body>
If you run the application after these changes, assuming you don’t have a login cookie active from a previous run, you will see the “You are not logged into this application” message. Like the /Logout link added to Index.razor at the beginning of the article, the /Login link after that message doesn’t do anything yet, **but that’s an easy fix.
Login and Logout
We’re going to use a pair of Razor Page OnGet handlers to address login and logout. Create a class called _HostAuthModel.cs in the Pages folder:
public class _HostAuthModel : PageModel
{
public IActionResult OnGetLogin()
{
return Challenge(AuthProps(), "oidc");
}
public async Task OnGetLogout()
{
await HttpContext.SignOutAsync("Cookies");
await HttpContext.SignOutAsync("oidc", AuthProps());
}
private AuthenticationProperties AuthProps()
=> new AuthenticationProperties
{
RedirectUri = Url.Content("~/")
};
}
Two changes are required at the start of _Host.cshtml – you must tell the @page directive to expect a handler parameter, and you have to wire up the class as the page model:
@page "/{handler?}
@model _HostAuthModel
The {handler?} parameter and how it relates to the model code is another one of a million little ASP.NET “conventions” that you just need to magically be aware of, which is annoying, but I have to admit it’s all pretty concise and simple once you figure out what to do.
And now if you run the project, everything works as expected. That’s all it takes.
Expiration Problem
Unfortunately, there is a minor problem with this approach – the login will never expire. This is cookie-based authorization, but Blazor server-side works over SignalR connections. Those are glorified websocket connections which do not have HTTP concepts, including cookies. Even more unfortunately, Microsoft’s current position on the problem seems to be, “We’ll think about it some day, go figure it out for yourself.”
There is a way to make it work, and in the next article, that’s what we’ll do.
**Update: The follow-up article is now available: Blazor Login Expiration with OpenID Connect.
No Easy Alternatives
I explored the possibility of creating a Blazor-specific OIDC authentication scheme which didn’t depend on HttpContext or cookies at all, but unfortunately the current ASP.NET Core authentication base classes assume HttpContext is available (for example, it’s part of the initialization call in the abstract AuthenticationHandler class). The middleware (registered via UseAuthentication in the Configure method) is also currently built upon the assumption of a primarily HTTP-oriented processing pipeline.
It would still be possible to create a scheme to store credentials that isn’t cookie-based, but if you have to use HttpContext you might as well use cookies, too. To avoid HTTP, however, would require writing a completely parallel authentication library that replicated most of the core security features of the framework – a very non-trivial task, particularly since the architecture is not well-documented and has few comments in the code.
Conclusion
I don't like that it doesn’t allow for very complex anonymous content. If your system has the opposite scenario – it needs a few secured features but is mostly open to anonymous use – the solution is to define specific authorization policies and decorate the secured content using Policies. Anyway you take this, it's just nice to have a solution.
Thanks /J