Introducing the OpenIddict client

When I unveiled the OpenIddict 3.0 roadmap three years ago, I mentioned that having an OpenIddict client would be a very nice addition but that implementing it as part of 3.0 wasn't realistic. Today, I'm very happy to announce that the OpenIddict client will ship as part of OpenIddict's next major version.

Why a new client?

A few client libraries already exist for .NET, including:

So why do we need another one? Am I reinventing the wheel by introducing another OIDC client stack?

Well, that's definitely a legit question. First, I have to say that these implementations work just fine and that I've used them happily other the years. Actually, I even contributed to some of these implementations multiple times so I have a fairly good experience working with them.

In a nutshell, here's what motivated me:

  • While all these libraries offer very specialized implementations, none of them offers a unified experience that allowing sharing a common code base usable on, say, ASP.NET Core and Blazor WASM applications: what you learned about the ASP.NET Core OIDC handler is not applicable to Blazor WASM, that uses a completely different OIDC client stack under the hood.

  • The OIDC integration for Blazor WASM uses oidc-client-js, that was archived in June 2021 and is no longer supported. While the Blazor OIDC wrapper itself is supported by Microsoft, the library it uses under the hood is not and won't receive any bug or security fix, which is a bit surprising to me, considering its author is still sponsored by Microsoft.

  • While it's supported by Microsoft, the Blazor OIDC wrapper doesn't actually get much love and suffers from annoying design issues that affect OpenIddict users that the team is unwilling to fix.

  • The OAuth 2.0 base handler for ASP.NET Core offers a straightforward API for creating derived OAuth 2.0 clients that we successfully used in the aspnet-contrib social providers. Unfortunately, its simplicity comes at a cost: it's not composable. Concretely, it means that every time we need to customize something (e.g adding a parameter to the token request), we end up duplicating more code than what we should (e.g to add a token request parameter, you also need to take care of sending the HTTP request and handling the response, which is something the OAuth 2.0 base handler does for you if you don't override the OAuthHandler<T>.ExchangeCodeAsync(OAuthCodeExchangeContext context) method).

  • There's also another downside with the OAuth 2.0 base handler: it doesn't support OpenID Connect. Yet, we received contributions to add OAuth 2.0 providers that also support OpenID Connect: while ASP.NET Core has a dedicated OIDC handler that is more appropriate for these cases, many people like the simpler registration story the OAuth 2.0 base handler and the aspnet-contrib providers offer. Retrospectively, we shouldn't have accepted these contributions as these providers don't benefit from the additional security checks a real OpenID Connect implementation requires (e.g nonce, at_hash or c_hash validation, etc.)

  • The OAuth 2.0 base handler doesn't support the OAuth 2.0 Authorization Server Metadata specification that backported the OpenID Connect server discovery feature to the OAuth 2.0 world and that allows finding the location of the authorization and token endpoints dynamically, without having to hardcode them. While the adoption is slow, I'd expect more and more services to support it in the next few years.

Can you tell me more about this new client?

Put simply, the OpenIddict client is closely modeled after the OpenIddict server and validation stacks and reuses many of their core concepts, including their extremely powerful events model, their modular approach and the host/transport-agnostic design of the core server and validation packages.

Concretely:

  • The OpenIddict client will be usable with any OAuth 2.0 and OpenID Connect server (based on OpenIddict or not) and will heavily use client/server negotiation to adapt its validation routines to the targeted authorization servers to guarantee maximum compatibility while ensuring the best level of security.

  • The OpenIddict client currently supports ASP.NET Core 2.1 and higher and native support for ASP.NET 4.x/OWIN will be added in the next few weeks. I also started working on a Blazor WASM prototype and while there'll be limitations (e.g Blazor WASM's encryption/signing support is extremely limited), things are really promising.

  • The code, implicit and hybrid flows are all natively supported (except response_type=token, which is unsafe) and OpenIddict has built-in logic to select the best flow to use (unlike the ASP.NET Core OIDC handler that uses the hardcoded response_type=id_token). It's worth noting the OpenIddict client was designed to allow implementing additional flows (like the OpenID Connect Client-Initiated Backchannel Authentication Flow) without requiring massive design changes.

  • The OpenIddict client will allow supporting multiple static authorization servers (I currently don't plan on adding dynamic client registration support, but it's something that may be added in a future version).

  • The OpenIddict client uses a different approach for handling authorization callbacks: while the OAuth 2.0 and OIDC handlers developed by Microsoft typically handle everything for you by flowing the ClaimsPrincipal extracted from identity tokens/userinfo responses to another authentication handler (in most cases, an instance of the cookies authentication handler), the OpenIddict client won't have this logic and will offer exactly the same approach as the OpenIddict server stack by encouraging users to be involved in the creation of the authentication cookie during the callback handling.

Concretely, the OpenIddict client will offer a pass-through mode to allow handling callbacks in a custom MVC action or minimal API handler. Here's an example of such an action:

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// Note: this controller uses the same callback action for all providers
// but for users who prefer using a different action per provider,
// the following action can be split into separate actions.
[HttpGet("~/signin-{provider}"), HttpPost("~/signin-{provider}")]
public async Task<ActionResult> Callback()
{
// Retrieve the authorization data validated by OpenIddict as part of the callback handling.
var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme);

// Multiple strategies exist to handle OAuth 2.0/OpenID Connect callbacks, each with their pros and cons:
//
// * Directly using the tokens to perform the necessary action(s) on behalf of the user, which is suitable
// for applications that don't need a long-term access to the user's resources or don't want to store
// access/refresh tokens in a database or in an authentication cookie (which has security implications).
// It is also suitable for applications that don't need to authenticate users but only need to perform
// action(s) on their behalf by making API calls using the access token returned by the remote server.
//
// * Storing the external claims/tokens in a database (and optionally keeping the essential claims in an
// authentication cookie so that cookie size limits are not hit). For the applications that use ASP.NET
// Core Identity, the UserManager.SetAuthenticationTokenAsync() API can be used to store external tokens.
//
// Note: in this case, it's recommended to use column encryption to protect the tokens in the database.
//
// * Storing the external claims/tokens in an authentication cookie, which doesn't require having
// a user database but may be affected by the cookie size limits enforced by most browser vendors
// (e.g Safari for macOS and Safari for iOS/iPadOS enforce a per-domain 4KB limit for all cookies).
//
// Note: this is the approach used here, but the external claims are first filtered to only persist
// a few claims like the user identifier. The same approach is used to store the access/refresh tokens.

// Important: if the remote server doesn't support OpenID Connect and doesn't expose a userinfo endpoint,
// result.Principal.Identity will represent an unauthenticated identity and won't contain any claim.
//
// Such identities cannot be used as-is to build an authentication cookie in ASP.NET Core (as the
// antiforgery stack requires at least a name claim to bind CSRF cookies to the user's identity) but
// the access/refresh tokens can be retrieved using result.Properties.GetTokens() to make API calls.
if (result.Principal.Identity is not ClaimsIdentity { IsAuthenticated: true })
{
throw new InvalidOperationException("The external authorization data cannot be used for authentication.");
}

// Build an identity based on the external claims and that will be used to create the authentication cookie.
//
// By default, all claims extracted during the authorization dance are available. The claims collection stored
// in the cookie can be filtered out or mapped to different names depending the claim name or its issuer.
var claims = new List<Claim>(result.Principal.Claims
.Select(claim => claim switch
{
// Map the standard "sub" and custom "id" claims to ClaimTypes.NameIdentifier, which is
// the default claim type used by .NET and is required by the antiforgery components.
{ Type: Claims.Subject } or
{ Type: "id", Issuer: "https://github.com/" or "https://twitter.com/" }
=> new Claim(ClaimTypes.NameIdentifier, claim.Value, claim.ValueType, claim.Issuer),

// Map the standard "name" claim to ClaimTypes.Name.
{ Type: Claims.Name }
=> new Claim(ClaimTypes.Name, claim.Value, claim.ValueType, claim.Issuer),

_ => claim
})
.Where(claim => claim switch
{
// Preserve the nameidentifier and name claims.
{ Type: ClaimTypes.NameIdentifier or ClaimTypes.Name } => true,

// Applications that use multiple client registrations can filter claims based on the issuer.
{ Type: "bio", Issuer: "https://github.com/" } => true,

// Don't preserve the other claims.
_ => false
}));

var identity = new ClaimsIdentity(claims,
authenticationType: CookieAuthenticationDefaults.AuthenticationScheme,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);

// Build the authentication properties based on the properties that were added when the challenge was triggered.
var properties = new AuthenticationProperties(result.Properties.Items);

// If needed, the tokens returned by the authorization server can be stored in the authentication cookie.
// To make cookies less heavy, tokens that are not used are filtered out before creating the cookie.
properties.StoreTokens(result.Properties.GetTokens().Where(token => token switch
{
// Preserve the access and refresh tokens returned in the token response, if available.
{
Name: OpenIddictClientAspNetCoreConstants.Tokens.BackchannelAccessToken or
OpenIddictClientAspNetCoreConstants.Tokens.RefreshToken
} => true,

// Ignore the other tokens.
_ => false
}));

// Note: "return SignIn(...)" cannot be directly used in this case, as the cookies handler doesn't allow
// redirecting from an endpoint that doesn't match the path set in CookieAuthenticationOptions.LoginPath.
// For more information about this restriction, visit https://github.com/dotnet/aspnetcore/issues/36934.
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), properties);

return Redirect(properties.RedirectUri);
}

By doing that, developers will have greater control over what's actually stored in their authentication cookies without requiring a claims mapping feature similar to what's used by the MSFT OIDC handler, that can sometimes be hard to use when you need to restore claims that are removed by the OIDC handler to help ensure the resulting authentication cookies don't hit cookie size limits.

  • The OpenIddict client will be fully suitable for delegation-only scenarios where authentication is not needed or where the identification of the user who authorized the client is not possible (typically, OAuth 2.0-only servers that don't have a userinfo endpoint). In this case, no information about the user will be available but the tokens will still be available to perform any API call on his behalf.

  • Currently, only the authorization/authentication part is fully implemented: userinfo will come in the next few weeks and logout support at a later date.

What's next?

I plan on working on a prototype evaluating the OpenIddict client as a potential replacement of the OAuth 2.0 base handler developed by Microsoft for the aspnet-contrib social providers. While the OpenIddict client is certainly a bit more complex, doing so would have interesting advantages:

  • By being natively compatible with multiple environments (ASP.NET Core, ASP.NET 4.x, Blazor WASM), a broader audience of developers could be reached at no additional cost. This could be a nice migration option for those using the OWIN OAuth providers (by which the aspnet-contrib providers were initially inspired), that are sadly no longer under active development.

  • Unlike the OAuth 2.0 base handlers, the OpenIddict client natively supports the client_secret_basic client authentication method, which would save us from having code that is needed just to attach the client credentials to the Authorization header of token requests in all the providers that don't support client_secret_post.

  • By being natively composable, the events model could be used to eliminate code is not strictly required for the social providers to work (e.g if we need to simply attach a custom parameter, we shouldn't have to also send the HTTP request and handle the response in each provider).

  • The story for social providers supporting both OAuth 2.0 and OpenID Connect would be much better than with the OAuth 2.0 base handler, as they would automatically benefit from all the validation checks implemented in the core OpenIddict client without requiring custom code.

  • As the OpenIddict client supports both static and dynamic server configuration, social providers that implement discovery could be easily updated to use dynamic configuration, which is not possible with the OAuth 2.0 base handler (that requires hardcoded endpoints).

This will certainly be points I'll discuss with Martin Costello - who co-owns the aspnet-contrib OAuth 2.0 providers with me - once I have a fully working prototype.

You can see the OpenIddict client in action in the sandbox project. If you're interesting in sharing your feedback, please don't hesitate to do so by posting a message in the dedicated GitHub discussion.