Can you use the ASP.NET Core Identity API endpoints with OpenIddict?

TL;DR: yes, but please, don't do it! 🤣

If you're an avid reader of Andrew Lock's blog (certainly one of the best .NET blogs out there!), you probably figured out that the title of this blog post is very similar to his latest post, Can you use the .NET 8 Identity API endpoints with IdentityServer?, in which he describes how you could (but really shouldn't 😂) use ASP.NET Core 8's Identity API endpoints with an OAuth 2.0/OpenID Connect server stack like IdentityServer or OpenIddict.

The similarity is of course completely deliberate, as his post motivated me to write this one.

The approach described in Andrew's post – that mainly consists in using the Identity API endpoints introduced by .NET 8 to handle the user authentication part – also works with OpenIddict, so there's no point covering the same aspects twice: if you haven't read it, please read Andrew's post before reading mine.

Instead, we're going to do something even crazier ('cause why not? 😎): using OpenIddict to process token requests handled by the Identity API login endpoint... and generate token responses containing either JWT or ASP.NET Core Data Protection access tokens!

ASP.NET Core Identity's API endpoints are faux-OAuth 2.0 endpoints...

As a preamble, it's important to note that while the ASP.NET team mentioned multiple times that the Identity API endpoints are not an OAuth 2.0 implementation, it's actually a non-standard equivalent of the OAuth 2.0 resource owner password credentials grant, as I demonstrated here.

(if you're not convinced yet these endpoints are "heavily inspired" by OAuth 2.0, this message posted by Stephen Halter – who wrote the ASP.NET Core Identity API endpoints feature – should speak for itself 😁)

... that can be used with OpenIddict nevertheless

Why is this "OAuth 2.0/not OAuth 2.0" distinction important you may ask? Well, since the ASP.NET Core Identity API endpoints implement a clone of the OAuth 2.0 resource owner password credentials grant with only a few differences, it's going to be very easy to use OpenIddict's advanced events model to make it compatible with the non-standard protocol created by the ASP.NET team.

Create a minimal ASP.NET Core API and enable the ASP.NET Core Identity API endpoints

For that, create a new .csproj referencing the ASP.NET Core Identity UI, the OpenIddict ASP.NET Core metapackage and the EF Core packages:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.0-rc.1.23421.29" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0-rc.1.23421.29" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.0-rc.1.23421.29" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0-rc.1.23419.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0-rc.1.23419.6" />
<PackageReference Include="OpenIddict.AspNetCore" Version="4.8.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="4.8.0" />
</ItemGroup>

</Project>

We'll also need a custom DbContext derived from IdentityDbContext:

1
2
3
4
5
6
7
8
9
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace OpenIddictIdentityEndpoints.Data;

public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: IdentityDbContext(options)
{
}

Last but not least, we'll also need an entry point registering the DI services and the middleware we'll need:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
using Microsoft.AspNetCore.Authentication.BearerToken;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using OpenIddict.Server.AspNetCore;
using OpenIddict.Validation.AspNetCore;
using OpenIddictIdentityEndpoints;
using OpenIddictIdentityEndpoints.Data;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseOpenIddict();
options.UseSqlServer("Server=(localdb)\\mssqllocaldb;Database=openiddict-identity-endpoints-sample;Trusted_Connection=True;MultipleActiveResultSets=true");
});

builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddAuthorization();

builder.Services.AddIdentityApiEndpoints<IdentityUser>()
.AddEntityFrameworkStores<ApplicationDbContext>();

builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore().UseDbContext<ApplicationDbContext>();
})
.AddServer(options =>
{
options.AllowPasswordFlow();
options.SetTokenEndpointUris("login");

options.AcceptAnonymousClients();

options.AddDevelopmentEncryptionCertificate();
options.AddDevelopmentSigningCertificate();

options.UseAspNetCore().EnableTokenEndpointPassthrough();
})
.AddValidation(options =>
{
options.UseLocalServer();

options.UseAspNetCore();
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error");
app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapGet("/", () => "Hello, World!");
app.MapGet("/requires-auth", (ClaimsPrincipal user) => $"Hello, {user.Identity?.Name}!").RequireAuthorization();

app.MapIdentityApi<IdentityUser>();

app.Run();

To initialize the database with the Identity and OpenIddict tables, run dotnet ef migrations add CreateOpenIddictSchema and dotnet ef database update. Once it's initialized, you can create a new account by sending a JSON payload to /register containing the email address and the password:

1
2
3
4
{
"email": "alice@wonderland.com",
"password": "11uP$4#^==@/"
}

So far, nothing fancy: EF Core, ASP.NET Core Identity and OpenIddict are configured like in any other app. The only interesting part is that OpenIddict is set up to allow the password flow and use the same /login address for its token endpoint as the login endpoint used by Identity (added for you when calling app.MapIdentityApi<IdentityUser>()).

Add custom event handlers to tweak the token endpoint processing logic

If you try to send a request to Identity's login endpoint, all you'll get is an OAuth 2.0 error returned by OpenIddict telling you that that the specified Content-Type HTTP header is not valid:

1
2
3
4
5
{
"error": "invalid_request",
"error_description": "The specified 'Content-Type' header is invalid.",
"error_uri": "https://documentation.openiddict.com/errors/ID2082"
}

It's expected: while the custom protocol derived by the ASP.NET team uses JSON as input, the standard OAuth 2.0 protocol uses formURL-encoded requests.

To accomodate that, we'll need to use OpenIddict's events model to extract OAuth 2.0 token requests from JSON payloads instead of formURL-encoded requests. We'll also need to map non-standard parameters and claims to their standard equivalent.

For that, we'll create an extensions class that will centralize our custom event handlers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using Microsoft.AspNetCore;
using OpenIddict.Abstractions;
using System.Diagnostics;
using System.Security.Claims;
using static OpenIddict.Abstractions.OpenIddictConstants;
using static OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlerFilters;
using static OpenIddict.Server.AspNetCore.OpenIddictServerAspNetCoreHandlers;
using static OpenIddict.Server.OpenIddictServerEvents;

namespace OpenIddictIdentityEndpoints;

public static class OpenIddictServerExtensions
{
public static OpenIddictServerBuilder AddCustomHandlers(this OpenIddictServerBuilder builder)
{
// Remove the built-in event handler responsible for extracting standard OAuth 2.0 token requests
// (that always use form-URL encoding) and replace it by an equivalent supporting JSON payloads.
builder.RemoveEventHandler(ExtractPostRequest<ExtractTokenRequestContext>.Descriptor);
builder.AddEventHandler<ExtractTokenRequestContext>(builder =>
{
builder.UseInlineHandler(async context =>
{
var request = context.Transaction.GetHttpRequest() ?? throw new InvalidOperationException();

if (!HttpMethods.IsPost(request.Method) || string.IsNullOrEmpty(request.ContentType) ||
!request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
{
context.Reject(Errors.InvalidRequest);
return;
}

// Enable buffering and rewind the request body after extracting the JSON payload to ensure
// the ASP.NET Core Identity API endpoint can also resolve the request parameters.

request.EnableBuffering();

try
{
context.Request = await request.ReadFromJsonAsync<OpenIddictRequest>() ?? new();
}

finally
{
request.Body.Position = 0L;
}

// Unlike a standard OAuth 2.0 implementation, ASP.NET Core Identity's login endpoint doesn't
// specify the grant_type parameter. Since it's the only authentication method supported anyway,
// assume all token requests are resource owner password credentials (ROPC) requests.
context.Request.GrantType = GrantTypes.Password;

// The latest version of the ASP.NET Core Identity API package uses "email" instead of the
// standard OAuth 2.0 username parameter. To work around that, the email parameter is manually
// mapped to the standard OAuth 2.0 username parameter.
context.Request.Username = (string?) context.Request["email"];
});

builder.AddFilter<RequireHttpRequest>();
builder.SetOrder(ExtractPostRequest<ExtractTokenRequestContext>.Descriptor.Order);
});

builder.AddEventHandler<ProcessSignInContext>(builder =>
{
builder.UseInlineHandler(context =>
{
Debug.Assert(context.Principal is not null);

// OpenIddict requires specifying the standard OpenID Connect "sub" claim that identifies
// the user. Since it's not specified by ASP.NET Core Identity's login endpoint, it is
// manually mapped from the ClaimTypes.NameIdentifier claim that is added by Identity.
context.Principal.SetClaim(Claims.Subject, context.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);

// Allow OpenIddict to store all the claims generated by ASP.NET Core Identity in the access tokens.
context.Principal.SetDestinations(static claim => [Destinations.AccessToken]);

return default;
});

builder.SetOrder(int.MinValue);
});

return builder;
}
}

You can now easily register your custom event handlers by calling the options.AddCustomHandlers() extension we just added:

1
2
3
4
5
6
7
builder.Services.AddOpenIddict()
.AddServer(options =>
{
// ...

options.AddCustomHandlers();
});

Amend the BearerTokenOptions to forward the authentication operations to OpenIddict

At this point, if you run the application as-is, you'll see that the /login responses are still generated by the ASP.NET Core Identity API stack and not by OpenIddict. The explanation is actually quite simple: we're still missing the secret sauce that is needed to let OpenIddict generate token responses for the requests handled by the /login API endpoint.

For that, we'll need to tweak the BearerTokenOptions to forward the sign-in operations to the OpenIddict server stack. It's also the right place to redirect token validation to the OpenIddict validation stack:

1
2
3
4
5
6
7
8
9
10
11
12
builder.Services.Configure<BearerTokenOptions>(IdentityConstants.BearerScheme, options =>
{
// Forward the sign-in responses returned by ASP.NET Core Identity's
// login API endpoint to the OpenIddict server stack so that OpenIddict
// can generate a token response based on the principal prepared by Identity.
options.ForwardSignIn = OpenIddictServerAspNetCoreDefaults.AuthenticationScheme;

// Forward the token authentication operations to the OpenIddict validation stack.
options.ForwardAuthenticate = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.ForwardChallenge = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
options.ForwardForbid = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme;
});

With that in place, if you send a /login request with valid user credentials, you'll see that a standard OAuth 2.0 response is returned by OpenIddict and contains an encrypted JWT token:

1
2
3
4
{
"email": "alice@wonderland.com",
"password": "11uP$4#^==@/"
}
1
2
3
4
5
{
"access_token": "eyJhbGciOiJSU0EtT0FFUCIsImVuYyI6IkEyNTZDQkMtSFM1MTIiLCJraWQiOiI2MDFFQ0YzOTE5Nzk0QjFEMDJGQzQ3NzM3QjdGQjVGMzAwN0NDQjgzIiwidHlwIjoiYXQrand0IiwiY3R5IjoiSldUIn0.Wex4QAYwlj-8LIp2IGOv5wgObeYzjQU_SqH50sIpfsyAq4OfY6D_xb1Zr1hedPd9WtpKR9xsVtVA6c0aMwMwTQBXCp4jb3p8i5pRk_B0_456SxtiWhzriYiEiQdJHn4trbGQJewYPlE8aoycqKj1vjpOFZVlV3Hl5b45ScBghozrnLXY-JTPl7RKmYGkVybQ5RuqBqUDOyQ6I8I8X53teZ2NlBYH3z52vpeiZ-pNxkjw4qf56Td5hqT0v0o3FFYXbrnP4g_ICC6AC91cRMeaOZioqA5EsQefvGDGIvCmVo0Ji4X0FPr5qZX8sMaBGllQ6IaQV69r8ROBW-0VdVTQjw.k36euUymXI0SUp74X8vN7A.w8tCJYZrsjlG1WZb4hJbWdBEMXFxkP_0R5mgJBNXCjTV2Y5FLq3kCcW3eRwc8qWKNlDBZZoOnCUjBxrtExk6mbZRyTCPni50G3iDYbXTHqtnYz7gor5AcAXM7_DobSgKetdQE4cV2jUNQEO4jbGj-CC7X3tPe_WOgXGY2rpjNVfanBUKtwsbruSDvqOTxYNTBJGdW7mPlbriWiUgoEso2Hhpas_IINJO-H3gic0FfhJfU9u02ObXs19Z1ZmfmfuZ4-_C6jdYPOaVA8cKjUOJPF8aodBZksStLYwcGLwf02x264o5uuM4meMRzQUN7OiWh4jOblhCSQXPXCSO5jBzUZNvg8h5GyxbtqOgir5UF99_bEdAw2PJMJIU8zVSE4iZLW3vvdKaZg9m9c46SXks9P3X_IiqPsQdSWzNRqHqOzn3nguub_ufw_2RfwYYCSgSfFqKrozMXSQxKR2iTrcqiJrpQjdut6ny9DxwcRjZxcXA4gcw_2CaBVsHaI16bFrSs17YkEycRRQRhGJYd8e50y5VmAwx8XJHn_ahoQEvMKgXexuKx1Vsk178fZRdW-QHA0a9qf0LrPUToVOSyhZrM2neSNSuXoa6VyJjQ0MzFEi829brhPnHqmn3qxs1HKT5olr5iGc_WZDcDGOFVdLstLVN2dixG8phgP6krYUXsy-O6u0Q5tpoLZLf_EQMSu_jExl-pm5F_5wDT0NrUC8frZqnHSQii_SLWRsUSu3ORMMwv_pHKVjCOeI19MUx_h6Djv2LzKujXNCcafj0rwRUKzIQG3CNQffNPASrznxT629k3nnEELP1R5i-AOIYF2msJdy0tiJ491zN3J0w9GyduTcxHwkxgACqeYCIOG3Td0pPdHDIsO-UKbUiNnZbRUjEYLMu2Rne6MJYG8ogJfXaWOszIyZxKolkcSKxguIyHacGpuxhnhsbwzHtvn_-wNnNOlD31TuO0M3I_Z_FquWvBllxqSMaYtAiVCM1jpXnRaRODY_u44iUuJ8fk9R3Bj0MaXfghtuRSiZsz5Hf6qTLV5xCbreB11YZFrVhKGsI58lSQoy02jNEZCHn3Ta-af3ygE2RzsqlGNF7v9UkgZpdRpXlYnbAIaHavy8F3ItTcmChTQHFFxf5ACEntZ-KnV3hXW1x3KrjyselrRdxQhNssAT5zpQVB6MGbLeK4brhU-SMQctZww6-lfN7Qw4fTfTXZVywmkf8KRLwNICDKihLHK8sQzuuttj6BYHJDmJrKh0p3haLovehKK6xanjxaffliKzkR6YM7S5l8MjiM79Vrw1YAqaXKfr-OaC5WYV1Mtlx4rUE9pgk9yMEsUOrcJqy4qMxgv1ER-4Q0_JxOCUAiHceDNtSDPAN4wELPsCmlp8YQJnofHtU5Umua33JyY9Wou6tLFFfLLY4kH1pFeDUOQt2KkDcHTSXqGw_xm1I8V_FeBMAhQ7_EaeTvaUnrSUqeB4-5XH3cbSD42wrEIwDKyvOjcg6wcZIF39JknOlfzY6dc-OVCWX2PGC3fj1Y7-XTIhg1MjByw4KdIVo9nyPBOUfV_Zaq371116ZoviafH-85ZbNap9VVAd9RZr_X2VmUa_w2MbVQY-dLsqB-Kjcn3JOZMMlcjM77BYp8gjGAvNPswxXwCZsto3wiv79ZYBnLcknBOKlxjQFwE0q8OR0YsNx_dkgyTd3IoJIz8Vi6Yg.bvOOUI76EBbQ2WhOXreO-w_0RXJf730vM5VTgRwVSwY",
"token_type": "Bearer",
"expires_in": 3600
}

What if I prefer ASP.NET Core Data Protection for my tokens?

Just like the ASP.NET Core Identity API endpoints, OpenIddict can use ASP.NET Core Data Protection as the token format. For that, just call options.UseDataProtection() from both .AddServer(...) and .AddValidation(...) and you'll get opaque tokens:

1
2
3
4
5
6
7
8
9
10
11
12
13
builder.Services.AddOpenIddict()
.AddServer(options =>
{
// ...

options.UseDataProtection();
})
.AddValidation(options =>
{
// ...

options.UseDataProtection();
});
1
2
3
4
5
{
"access_token": "CfDJ8MjLq6GgozVHmdX5Xs26TkbrOWF10aeBeP4fUyy-Vyu1D7PqJyDxV_NAW0AnCB1OaJzpyQ9CP8HIRQI9mSOzLSpdO9OBlFc9NG5tJ9D6tHDeiuKvOZb3eIngnsgs0NDq_zf8zionU2qi4kerpP-sgOvo8Fk5uqi66AHmXTLyJFLyDCOPBnBQmrT3RLBrhMFkfkDajHaqhWRG29sqK_YHeiIWzUK4JLkHpyYdypcpmHCyBknkt8NFXXDtBEdbIkrVy4-LHPuLGGArzbP9R7RX_vIKxdwNZ-nEa_kHiw9sPHgJhqIH574kDetczGxnJHLLDpZ1cVAqfr8OUTSo4yq75OtPh9D4pbv7YhpZ2mfAiNFBNJno5X6cwzbG1SeopLfnXL2KBF0bcsmQqRx7bEzgzLZejMLpJs5Slfwf88wgOepBzGmcfmU3fakTW9n8S9aBFDFmeysbmzFjR4rbp-5e1qZRcsO0fzmQE17oejCFN7LvFTkZikF6yce_maOLio9V9452xGU0X5izxjqhTBqHNZSfE8KFBCe5CeJ8LL5lExch18gWM6I9jIybRbZ2mLQJjiNDnvD5UbBVgDWIcLZMf54jli7XXjFIXZzCfCta_5kNtJ9TtjinVFLIeXjt2JkAaBMjnbUoqDV7ORyOjKxhddqOvq7A9jtJ3_m2aZ5H5jUhBuUwuuSWjPbdPdIH_feNcnrp9jDGMtR1F7mdR-BU-ok6gPKzL6WVDSwXQFGUF2SyOsWtP1i0U8sH4grl4ik5aFiQc-zuScjaVk7P6osXpVxDbb0kHODi0gxPRv4UGIve6GOjzoB6HUFyO2oQq0RtjlomxMw2IMo8OyiCKdAqr6F0d-QIfT5al39hX26k_zYTBKjjb9o_ylG1K6GrDNOoOzadiD-N2-7j6hNWSc55iXKryqoZpvdgPZKa6PS25RNFs9lA7hSopLRb3XQGOX-GpA",
"token_type": "Bearer",
"expires_in": 3600
}

Wait, how does that sorcery work?

If you omit the custom event handlers needed to work around the non-standard aspects of Microsoft's protocol/ROPC-clone, you can see that there's actually no horrible hack required to use OpenIddict as a replacement for the default ASP.NET Core Identity API response generator.

But you might be wondering: how does that work concretely?

This "voodoo magic" (🤣) is actually made possible by two OpenIddict features:

  • A native IAuthenticationHandler integration: just like the ASP.NET Core cookie authentication or the OIDC client handler developed by Microsoft, OpenIddict integrates with ASP.NET Core by registering an IAuthenticationHandler implementation and by configuring an authentication scheme that can be called to generate a token response. By redirecting the ForwardSignIn scheme to OpenIddict, we're actually bypassing the default response generation logic and replacing it by OpenIddict's logic, in a completely transparent way.

  • A built-in request pass-through mode: inherited from Katana's OAuthAuthorizationServerMiddleware, OpenIddict features a powerful pass-through mode that allows handling a token request outside the OpenIddict processing pipeline: OpenIddict starts processing the request by validating it and immediately asks ASP.NET Core to keep invoking the rest of the middleware chain so that the request can be handled in an MVC controller, in a minimal API action or in a middleware. When the application wants to return a valid token response, all it has to do is call SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme) with a ClaimsPrincipal instance containing the claims that will be used to create access tokens (or other types of tokens for more elaborate flows).

It's cool, but should I really do that?

No! While this works, I wouldn't recommend using this approach. Actually, I wouldn't really recommend using the ASP.NET Core Identity API endpoints in most cases, for exactly the same reasons Andrew Lock mentioned in his Should you use the .NET 8 Identity API endpoints? post (you can't even disable each API endpoint individually! What if you don't want to let users create accounts themselves...?!)

It's also worth noting that the OAuth 2.0 resource owner password credentials grant is not the most flexible flow: it's unusable with third-party applications (since they have a direct access to the username/password, which defeats the whole purpose of using OAuth 2.0), it's incompatible with social providers authentication and passwordless authentication. In general, options like the OpenID Connect code flow are much better and much more flexible.