blog post image
Andrew Lock avatar

Andrew Lock

~15 min read

Can you use the .NET 8 Identity API endpoints with IdentityServer?

In a previous post, I introduced the new ASP.NET Core Identity APIs that have been added as part of .NET 8. In a subsequent post I described some reasons why I generally don't think you should use them.

In this post I look at how those Identity APIs interact and relate to IdentityServer (and OpenIddict). I describe what the purpose of each of the approaches is, when you might want to use one or the other, and what happens if you try to use both!

The new Identity APIs in .NET 8

I described the new Identity APIs in some detail in a previous post, including why they were created and what they replace. I'm not going to rehash that all here, so do check that post if this is the first you're hearing of them. Instead I'll give a brief description of where they fit in, the problems they are intended to solve, and the niche they occupy.

I also strongly suggest reading my follow up post in which I describe some of the reasons I don't think you should use the Identity APIs in your application. The advice in that post still stands—I don't think the Identity APIs are a good idea, and instead recommend you use the existing ASP.NET Core Identity support, optionally with IdentityServer or OpenIddict if you need token support.

ASP.NET Core Identity is an additional layer/framework that ships with ASP.NET Core, and provides a wide variety of services for managing user accounts in your ASP.NET Core application. This includes both abstractions and default implementations for each service.

Adding ASP.NET Core Identity to your application can be relatively simple, but the end result is a series of ~30 Razor Pages, styled in a particular way (bootstrap), and which still require customisation (to remove the calls to action, for example).

image of sign-in screen

This is perfectly acceptable for many use cases—the Razor Pages can be customised, they have obvious areas you can extend and customise the wording for, and they have support for most flows you would like to use. And if you're building an MVC or Razor Pages application in particular, then simply updating the styling of the pages a bit may be good enough.

One of the downsides when you want to change the styling is that the page scaffolder dumps all of the Razor Page code into your project—the layout files, the Razor views, but also the PageModels containing all the logic. That means you're on the hook for keeping those PageModels up to date! An approach I like to take is to customise the Razor Pages without scaffolding the PageModels. You can read how to do that in a previous post, or in chapter 23 of my book!

The difficulty comes in if you're not building a server rendered MVC app or SPA app because

  1. The full-page-refresh of Razor Pages can be jarring when the rest of an app is a client-rendered SPA.
  2. SPA apps commonly use Bearer tokens for authentication, not cookies (more on that later). The default UI has no built-in way to issue bearer tokens.

The new Identity API endpoints are intended to solve both of these issues.

First of all, the Identity API endpoints are (as the name suggests) API endpoints rather than Razor Pages. They're implemented as a standard set of minimal API endpoints that you can add to your app by calling MapIdentityApi() on your WebApplication:

app.MapGroup("/account").MapIdentityApi<AppUser>();

This adds endpoints for creating a new user, for creating bearer tokens, for managing your user, etc.

Screenshot of the swagger UI showing all the endpoints available

By making these APIs, it's now easy to integrate the identity management pages into your SPA app. No jarring full-page refreshes, no differences in styling, just one seamless app! And one thing that's not apparent from the above endpoint list is that the /login endpoint can return bearer tokens. So if you want to use bearer tokens with your SPA app, now you can.

⚠ Warning I strongly suggest you don't use these bearer tokens, for the reasons described in my previous post.

At this point, a common question I've seen is "does this replace IdentityServer, OpenIddict, or <some token server>". The simple answer to that is: no.

For the sake of argument I'll leave aside the fact that the "token support" provided by the Identity APIs has several fundamental issues (i.e. it's a non-standard implementation of a deprecated OAuth 2.0 grant type that you should avoid). I (and others) have described those issues elsewhere already.

Another reason why these APIs won't replace the more established OpenID Connect servers is that the bearer token support added to the Identity API endpoints is not a JWT token. It's an opaque token that is encrypted with the Data Protection system, and essentially contains the same data as the cookie equivalent created by the Razor Pages default Identity UI. That means:

  • You can't issue tokens to non-interactive-users (i.e. for server-server clients), you always need a username and password.
  • You can't use the endpoint to create tokens that can be used by other services.

The Identity API endpoints sit fundamentally in a different domain of applicability to an OpenId Connect server like IdentityServer. The Identity API endpoints provide APIs for authenticating with that app, and that is all. IdentityServer and OpenIddict provide something very different.

IdentityServer: what you get in the box

In this section I describe briefly what you get in IdentityServer, and also what you don't get. I believe this all applies to the OpenIddict library as well, but I haven't personally used that library.

A potentially important caveat is that I haven't used IdentityServer in anger for a couple of years either, so take what I say here with a pinch of salt!

IdentityServer provides a thorough implementation of the OpenID Connect and OAuth 2.0 specifications. It provides these as a series of API endpoints, middleware, and associated infrastructure that you add to your ASP.NET Core application. These endpoints provide features such as:

  • Issuing tokens for interactive users (users on your website)
  • Issuing tokens for application clients (server-to-server apps)
  • Delegating authentication (server action on-behalf of a user)
  • Token verification
  • Automatic discovery and registration
  • Dynamic client registration

As well as many more!

However, one of the things that isn't built into IdentityServer is user management and authentication. IdentityServer doesn't prescribe how you need to provide this functionality, but ASP.NET Core Identity is an easy option.

IdentityServer + ASP.NET Core Identity

The IdentityServer documentation is very clear that the responsibility for logging-in and user management lies with your existing application; the IdentityServer middleware adds the additional protocol endpoints, which relies on having an authenticated user.

Diagram of the general IdentityServer/OpenIddict architecture in ASP.NET Core. Based on The big Picture by Duende.

If you're building a brand new IdentityServer application, and you don't have an existing set of user accounts to incorporate, then using ASP.NET Core Identity in conjunction with IdentityServer makes a lot of sense. So much sense in fact, that Duende provide a dotnet new template for getting started!

You can install the templates by running

dotnet new install Duende.IdentityServer.Templates

Which will add several new templates to your .NET SDK. In this post I'll use the isaspid to create a basic Duende IdentityServer app that uses ASP.NET Core Identity for user management:

dotnet new isaspid

When you run this, you're given the option to seed the database:

The template "Duende IdentityServer with ASP.NET Core Identity" was created successfully.

Processing post-creation actions...
Template is configured to run the following action:
Actual command: dotnet run /seed
Do you want to run this action [Y(yes)|N(no)]?

If you choose yes, the template builds the app and runs dotnet run /seed, which pre-seeds the database with a couple of users.

The IdentityServer template is pretty extensive, as you can see from the list of RazorPages included. Interestingly, it doesn't use the default Identity UI, and instead implements only Login, Logout, and External Login functionality:

The Razor Pages included in Identity Server

Generally speaking, your IdentityServer app should be as simple as possible, so that you don't expose more attack surface than necessary. After all, it's one of the most security critical apps you're likely to be running; if your IdentityServer app is compromised, it's possible all your apps can be compromised. For that reason, you're likely going to be perfectly happy using Razor Pages, even if you typically use a client side framework.

But what if you're not happy using Razor Pages for your identity app, and instead want to use a client-side app like Angular, React, or Blazor? That's where the Identity API endpoints come in…

Using IdentityServer with the Identity API endpoints

I was wondering - would it be possible to use the Identity API endpoints with IdentityServer? Does that even make sense?

Again, bear in mind that I don't generally recommend you use the Identity API endpoints anyway. The remainder of the post is predominantly a mental exercise!

On first thought, things seemed like they would get messy quickly. IdentityServer generally relies on cookie authentication. Plus managing the difference between the Identity API opaque tokens and IdentityServer's JWT tokens seemed like a recipe for confusion. But there's a simple solution to that: don't use the Identity API tokens and keep using cookies instead!

The Identity API /login endpoint contains two optional querystring parameters:

  • bool? cookieMode—when true, the endpoint does a standard cookie-based login, and doesn't return access tokens
  • bool? persistCookies—when false, the cookies are set as "session" cookies, so they are removed when you close your browser.

So you can login by making a POST to the /login Identity API endpoint, setting thse values, and providing the username and password:

### Login (Cookie mode, persist cookies (i.e. not session cookies))
POST http://localhost:5117/account/login?cookieMode=true&persistCookies=true
Content-Type: application/json

{
  "username": "[email protected]",
  "password": "SuperSecret1!"
}

After receiving a 200 response back, any time you make a request to your app, the browser automatically sends across your cookie for authentication. What's more, as you're authenticated using the standard Identity cookie authentication, it works well with IdentityServer too!

There's caveats to this, you need to make sure you are using a modern browser with SameSite cookies to avoid session fixation attacks (thanks Kévin Chalet for pointing it out so clearly!).

But before we try it out on the IdentityServer project, we should probably pause and ask: is this really a good idea?

Is this a good idea?

Probably not 😅

The main question is why do you want to use the Identity APIs in IdentityServer? As the centerpiece of your companies authentication story, you probably want to keep your IdentityServer app as simple as you can. Adding in client-side development by using Blazor or any other front-end framework seems like overkill to me. And that's the only real reason you would want to use the Identity APIs.

Nevertheless, lets assume you do still want to go this route. Maybe circumstances dictate that this will overall be simpler for "some reason". If so, then technically the Identity APIs should work just fine with IdentityServer if you use cookie authentication. But there are some caveats.

The main issue is that you can't customise the endpoints added in any way. That means the /register route which allows a new user to register will be added to your app. If you're using IdentityServer, you might not want unfettered user registration. What's more, there's no way to customise the information provided during registration, as you can't customise the endpoints.

I discussed these limitations (and others) in my previous post. What's more, the comments on that post reinforce the general concerns I expressed.

Another potential concern with using these new endpoints in your IdentityServer is, well, they're new. And new tends to mean more bugs. There's a few issues tagged with area-identity here, with one or two of the issues looking somewhat problematic, though they'll hopefully be fixed by the time .NET 8 is released. The code is largely the same as the default Razor Pages UI code, so it should be pretty safe, but you never know until people start using it!

But enough of the serious engineering stuff, I've hopefully made it clear that I don't think this is a good idea. Now let's see if we can wedge the Identity API endpoints into IdentityServer for the fun of it! 😄

Updating the IdentityServer template to use the Identity API endpoints

In this section I'll show how I replaced the existing login/logout Razor Pages that come with the Duende IdentityServer ASP.NET Core Identity template with the Identity API endpoints. I didn't create a whole front end as this was just a proof-of-concept to see if it could be done!

Updating to .NET 8

I already described how to create a new IdentityServer app with ASP.NET Core Identity by installing the Duende templates and then running

dotnet new isaspid

isaspid stands for "IdentityServer with ASP.NET Core Identity".

I hit Y to seed the SQLite database with a user so that we can easily login, and ran dotnet run to startup the application. The home page gives some easy links to check the claims of the current logged in user etc.

The IdentityServer homepage from the template

Before we go any further, we need to update the template to .NET 8, as the template version I was using used .NET 6. Luckily, updating to .NET 8 was as simple as updating the target framework and updating the NuGet packages! The final .csproj file looked like this:

<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
  <TargetFramework>net8.0</TargetFramework>
  <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Duende.IdentityServer.AspNetIdentity" Version="6.3.3" />

  <PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.0-preview.7.*" />
  <PackageReference Include="Serilog.AspNetCore" Version="6.0.0" />

  <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.0-preview.7.*" />
  <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0-preview.7.*" />
  <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.0-preview.7.*" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0-preview.7.*" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0-preview.7.*" />
</ItemGroup>
</Project>

I tried to build and run, and amazingly, everything just worked! There could be some corners I missed, but this really is a testament to the work the .NET team have done in building stable APIs.

With the template updated, we can go about adding the identity endpoints

Adding the Identity API endpoint services

Much of the code in my previous post was around adding EF Core and ASP.NET Core Identity to an application, but this is already all setup in the IdentityServer template. All we need to add are the services required by the Identity endpoints. The easiest way to do this is to call AddApiEndpoints() on the IdentityBuilder returned by AddIdentity<ApplicationUser, IdentityRole> in HostingExtensions:

// program.cs
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// HostingExtensions.cs
builder.Services.AddRazorPages();

builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddApiEndpoints() // 👈 Add this
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();

This is different to the AddIdentityApiEndpoints<>() method which you typically call on IServiceCollection to add the Identity endpoints. We already have the call to AddIdentity<> which already adds most of the same services (and more!), so we just need AddApiEndpoints to add the last few services. In fact, this only registers two missing services, but that's all we need!

builder.Services.TryAddTransient<IEmailSender, NoOpEmailSender>();
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<JsonOptions>, IdentityEndpointsJsonOptionsSetup>());

That's all the services configured (for now), so now we can add the endpoints.

Adding the API endpoints

To avoid any name clashes, I deleted the existing Accounts folder from the application which contains all the Razor Pages. I then added the Identity endpoints using the following:

app.MapGroup("/account")
   .MapIdentityApi<ApplicationUser>(); // Map all the Identity endpoints
        
app.MapPost("/account/logout", // Add a logout endpoint
    ctx => ctx.SignOutAsync(IdentityConstants.ApplicationScheme));

Notice that I also had to add a logout endpoint. As they're really intended for token-based authentication, the Identity endpoints don't include a way to logout when using cookie authentication. Luckily it's easy to add one, as shown above

That's all we need to try it out, so let's do that!

Trying it in a browser

If you run the application, you're taken to the same homepage as before

The IdentityServer homepage from the template

Obviously if you were rewriting your app to use a client-side framework you wouldn't be using Razor Pages here, but we'll ignore that for now. Instead, we'll emulate calls from a SPA to our endpoints. Open a developer window on the webpage (press F12), and run the following JavaScript:

var result = await fetch("/account/login?cookieMode=true&persistCookies=true", {
    method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify({ "username": "alice",  "password": "Pass123$" })
})

This makes a fetch request to the login endpoint, and if the request succeeds (it should), it sets the all-important Identity cookie. With the cookie set, you can now navigate to the /diagnostics page that comes with the IdentityServer template, and which shows the details about the authenticated user:

The IdentityServer claims page

To logout, you can make the following request:

var result = await fetch("/account/logout", { method: "POST"})

and you'll now be logged out. If you attempt to view the diagnostics page again, you'll get a 405 Method Not Allowed error:

405 error when attempting to view the protected page

So what's going on here?

Unfortunately, this is a whole big rabbit hole, and I'm not going to go into in detail here. In summary, this is a misconfiguration of ASP.NET Core's core authentication primitives, due to the fact that IdentityServer expects you to be using Razor Pages for authentication, not APIs. With Razor Pages, the authentication system will redirect the browser to the login page. When using APIs, you need the authentication system to return a "raw" 401/403 status code response instead.

ASP.NET Core needs to know what to do when an unauthenticated user tries to access a protected page, or when an authenticated user tries to access a page they're not authorized to access. It requires specifying authentication schemes and handlers and I think it's some of the most complex machinery in the whole of ASP.NET Core.

The prospect of having to solve those issues rather took the wind out of my sails to be honest, especially as I personally don't think this is a setup you should ever really use. I'm sure they're solvable, but at this point, it's no longer fun trying to figure out the issue, and it's too much like work 😅

Summary

In this post I discussed the new Identity endpoint APIs introduced in .NET 8 and how they relate to OpenID Connect implementations like IdentityServer and OpenIddict. I described how these solutions rely on the standard ASP.NET Core Identity framework for authentication and user management, providing a "token issuing and verification" layer on top of the existing cookie authentication.

In the second half of the post I investigated whether I could replace the standard Razor Pages-based login screen in one off the default Duende IdentityServer template with the .NET 8 Identity endpoint APIs. This was partially successful: I could login with the Identity APIs in cookie-mode, and IdentityServer then worked as expected. However, the authentication handlers were not correctly configured to handle challenge (and other) authentication events correctly.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?