Introducing system integration support for the OpenIddict client

Note: this blog post was updated to use the new record-based APIs introduced in OpenIddict 4.5.

When I unveiled the new OpenIddict client stack a year ago, I mentioned that one of the core design goals was to avoid coupling it to ASP.NET Core to eventually allow using it basically everywhere. With the release of OpenIddict 4.1, I'm making one additional step towards this goal by adding experimental support for Windows and Linux applications.

Why is a dedicated system integration package necessary?

While the OpenIddict client can already be used as-is to implement non-interactive flows like password or client credentials (thanks to its dedicated APIs in OpenIddictClientService), interactive flows like the code, hybrid or implicit flows are more complicated to implement, as they typically require launching the system browser (or using some sort of web view) to redirect the user to the authorization server and handling the authorization callback, which is generally implemented using an embedded web server or by registering a custom protocol URI scheme.

Leaving these critical parts as an exercise wouldn't offer a great experience. To avoid that, the new OpenIddict.Client.SystemIntegration package takes care of launching the user's preferred browser and handles the authorization responses returned by the identity provider to the protocol URI scheme associated with the application (or posted to the embedded web server), in a completely transparent way.

Once configured, doing a complete code flow dance should be as easy as:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
try
{
// Ask OpenIddict to initiate the authentication
// flow (typically, by starting the system browser).
var result = await _service.ChallengeInteractivelyAsync(new()
{
ProviderName = provider
});

// Wait for the user to complete the authorization process.
var response = await _service.AuthenticateInteractivelyAsync(new()
{
Nonce = result.Nonce
});

MessageBox.Show($"Welcome, {response.Principal.FindFirst(ClaimTypes.Name)!.Value}.",
"Authentication successful", MessageBoxButton.OK, MessageBoxImage.Information);
}

catch (ProtocolException exception) when (exception.Error is Errors.AccessDenied)
{
MessageBox.Show("The authorization was denied by the end user.",
"Authorization denied", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}

How does that work?

If you're already using OpenIddict, you probably already know that it makes heavy use of IServiceCollection to support dependency injection and simplify the configuration process by exposing dedicated IServiceCollection extensions and builders.

The new OpenIddict.Client.SystemIntegration package goes further by also leveraging the .NET Generic Host to implement all the hooks and plumbing needed to handle the authorization callbacks that will be returned to the application.

But concretely, how does that work?

  • When you call AuthenticateInteractivelyAsync(), OpenIddict launches the system browser (using either Launcher.LaunchUriAsync() or ShellExecuteEx() on Windows or xdg-open on Linux). While not recommended for most scenarios, WebAuthenticationBroker can also be used on UWP if you prefer a web-view-like-but-better approach that doesn't involve launching the system browser.

  • When the user approves the authorization demand, the response is returned to a callback URI, that can materialize as two things, depending on the type of URI:

    • A protocol activation that is received and managed by the operating system (e.g com.contoso.client:/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz). For that, at least one custom URI scheme must be registered with the OS:

      • For packaged Windows applications (e.g UWP applications or packaged WinForms/WPF/WinUI 3 applications), by declaring the desired URI scheme in the application manifest (e.g <uap:Protocol Name="com.contoso.client"/>). For more information, see Handle URI activation.

      • For non-packaged Windows applications (e.g traditional Win32 WinForms/WPF applications), by adding a registry entry for the desired URI scheme pointing to the executable that will be launched to handle the protocol activation:

      • For Linux applications, by adding a [Desktop Entry]. For more information, see Create a custom URL Protocol Handler.

    • A loopback HTTP request (e.g http://localhost:49158/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=xyz). For scenarios where registering a protocol handler registration is not possible or practical, it is possible to use the embedded HTTP web server that ships with OpenIddict.Client.SystemIntegration: when the application starts, OpenIddict automatically chooses a random port in the 49152-65535 range and starts listening to callback HTTP requests sent to localhost, pretty much like how that would work with OpenIddict.Client.AspNetCore or OpenIddict.Client.Owin.

  • To extract and handle protocol activations transparently in multi-instance applications, OpenIddict implements a blocking IHostedService that will determine whether the current application instance was created to react to a protocol activation (either using the WinRT AppInstance.GetActivatedEventArgs() API or by extracting the protocol activation URI from the command line arguments). If so, it invokes the OpenIddict client pipeline to handle the authorization response: once the response is validated, it is redirected to the correct instance (whose identifier is stored in the state token) and the current instance is terminated.

  • To handle authorization responses redirected by other instances, it implements a background IHostedService that waits for inter-process notifications to be posted to a named pipe. Once the authorization response is transferred, it is validated and the call to AuthenticateInteractivelyAsync() returns the final response with the authentication details.

While the internals are a bit complicated (as there are multiple application models and many scenarios to cover), it is fortunately completely transparent for the developer.

What types of applications can be supported with this new integration?

The OpenIddict.Client.SystemIntegration package doesn't depend on a specific application model and has been designed to be usable in most types of Linux and Windows applications (whether they are packaged or not and run full-trust or in an AppContainer).

That said, two technical aspects will limit cases where the OpenIddict client can be used:

  • .NET Standard 2.0 support: OpenIddict depends on packages that require .NET Standard 2.0 support (for instance, the Microsoft.Extensions.* packages), which excludes all the applications that run on a limited .NET flavor, like Windows 8's universal apps or UWP applications prior to Windows 10 1809, as these legacy platforms don't expose any of the APIs introduced in .NET Standard 2.0.

  • .NET Generic Host support: while the .NET Generic Host can be theoretically used in any application that can target .NET Standard 2.0, not all application models will offer a perfect experience:

    • Windows and Linux .NET console applications don't need anything specific as the .NET Generic Host already ships with a built-in .UseConsoleLifetime() extension that takes care of managing the lifetime of the host (typically, by listening to CTRL+C combinations and SIGTERM events).

    • WinForms and Windows Presentation Foundation applications can reference the excellent Dapplo.Microsoft.Extensions.Hosting.WinForms and Dapplo.Microsoft.Extensions.Hosting.Wpf packages developed by Robin Krom: the result is both very clean and perfectly integrated.

    • While there's currently no .NET Generic Host companion package for WinUI 3 applications, a pull request proposed by Jöra Malek should address that in the future: Implement WinUI.

    • To my knowledge, there's no integration for WinUI 2/UWP applications, which makes using the .NET Generic Host more complicated. There are also other annoying limitations, like Entity Framework Core not fully supporting UWP (and since .NET Standard 2.0 is no longer supported by recent versions of EF Core, proper UWP support will very likely never happen). Given that Microsoft pretty much halted the development of the UWP platform, using the OpenIddict client in UWP applications should be reserved to developers who are familiar with UWP and its inherent limitations.

    • While it features an application builder that is inspired by the .NET Generic Host, MAUI doesn't support any of the .NET Generic Host abstractions, like IHostedService or IHostApplicationLifetime (that are required by the OpenIddict system integration). The MAUI team is already aware of this limitation, but since there are persistent rumors indicating that the MAUI project is under-funded and doesn't have the human resources needed for such a project, it's not clear whether things will change any time soon. In the meantime, it is possible to work around that by using adapters, as shown in this pull request: Add a MAUI (WinUI-only) client sample.

    • Similarly to MAUI, Avalonia UI doesn't natively support the .NET Generic Host, but it should be possible to use it side-by-side with the regular Avalania UI host model.

To make things – hopefully – a bit easier, here's a matrix listing the different Windows/.NET versions and application models:

Windows version.NET runtime versionConsoleWinFormsWPFWinUI 2WinUI 3MAUI
Windows 7 SP1.NET Framework 4.6.1
Windows 7 SP1.NET Framework 4.7.2
Windows 7 SP1.NET Framework 4.8
Windows 7 SP1.NET 6.0
Windows 7 SP1.NET 7.0
Windows 8.1.NET Framework 4.6.1
Windows 8.1.NET Framework 4.7.2
Windows 8.1.NET Framework 4.8
Windows 8.1.NET 6.0
Windows 8.1.NET 7.0
Windows 8.1.NET Native/UAP
Windows 10 1507.NET Framework 4.6.1
Windows 10 1507.NET Framework 4.7.2
Windows 10 1507.NET Framework 4.8
Windows 10 1507.NET 6.0
Windows 10 1507.NET 7.0
Windows 10 1507.NET Native/UAP
Windows 10 1809.NET Framework 4.6.1
Windows 10 1809.NET Framework 4.7.2
Windows 10 1809.NET Framework 4.8
Windows 10 1809.NET 6.0
Windows 10 1809.NET 7.0
Windows 10 1809.NET Native/UAP
Windows 11 21H2.NET Framework 4.6.1
Windows 11 21H2.NET Framework 4.7.2
Windows 11 21H2.NET Framework 4.8
Windows 11 21H2.NET 6.0
Windows 11 21H2.NET 7.0
Windows 11 21H2.NET Native/UAP

Microsoft officially stopped supporting Windows 7 in .NET 7.0. As such, applications that still need to be usable on Windows 7 should probably stay on .NET Framework 4.8 (or .NET 6.0, but it should be noted that it will reach EoL in November 2024).

What do I need to know before using OpenIddict.Client.SystemIntegration?

As of OpenIddict 4.5, OpenIddict.Client.SystemIntegration is no longer considered experimental and using <EnablePreviewFeatures>true</EnablePreviewFeatures> is no longer necessary.

First, it's important to note that OpenIddict.Client.SystemIntegration may be subject to API or behavior changes depending on the feedback received after the OpenIddict 4.1 release. To reflect that, the OpenIddict.Client.SystemIntegration package explicitly requires adding <EnablePreviewFeatures>true</EnablePreviewFeatures> to your .csproj file to use any of its APIs.

Given that they all share the same base OpenIddict.Client package, using OpenIddict.Client.SystemIntegration is not fundamentally different than using OpenIddict.Client.AspNetCore or OpenIddict.Client.AspNetCore:

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
services.AddDbContext<DbContext>(options =>
{
options.UseSqlite($"Filename={Path.Combine(Path.GetTempPath(), "contoso-client.sqlite3")}");
options.UseOpenIddict();
});

services.AddOpenIddict()

// Register the OpenIddict core components.
.AddCore(options =>
{
// Configure OpenIddict to use the Entity Framework Core stores and models.
// Note: call ReplaceDefaultEntities() to replace the default OpenIddict entities.
options.UseEntityFrameworkCore()
.UseDbContext<DbContext>();
})

// Register the OpenIddict client components.
.AddClient(options =>
{
// Note: this sample uses the authorization code and refresh token
// flows, but you can enable the other flows if necessary.
options.AllowAuthorizationCodeFlow()
.AllowRefreshTokenFlow();

// Register the signing and encryption credentials used to protect
// sensitive data like the state tokens produced by OpenIddict.
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();

// Add the operating system integration.
options.UseSystemIntegration();

// Register the System.Net.Http integration and use the identity of the current
// assembly as a more specific user agent, which can be useful when dealing with
// providers that use the user agent as a way to throttle requests (e.g Reddit).
options.UseSystemNetHttp()
.SetProductInformation(typeof(Program).Assembly);

// Add a client registration matching the client application definition in the server project.
options.AddRegistration(new OpenIddictClientRegistration
{
Issuer = new Uri("https://localhost:44395/", UriKind.Absolute),
ProviderName = "Local",

ClientId = "console",
RedirectUri = new Uri("callback/login/local", UriKind.Relative),
Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" }
});
});

That said, two specific points deserve a special attention:

Signing and encryption credentials

Just like the ASP.NET Core and OWIN hosts, OpenIddict.Client.SystemIntegration requires registering a signing and an encryption key (or a X.509 certificate) to protect the state tokens created by OpenIddict. There are, however, important requirements that must be respected for production applications:

  • The keys MUST be stored in a place that is only accessible by the user account running the application (i.e each user MUST have his/her own key set).
  • The same keys MUST be accessible by all the instances of the application running under the same user account.

On Windows, the recommended option is to use Microsoft's Cryptography API: Next Generation API (aka CNG) to generate on-the-fly and persist the keys in a safe place. Here's an example:

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
static RsaSecurityKey GetRsaCngKey(string name, CngKeyUsages usages, CngProvider provider = null)
{
provider ??= CngProvider.MicrosoftSoftwareKeyStorageProvider;

CngKey key;

if (CngKey.Exists(name, provider, CngKeyOpenOptions.UserKey))
{
key = CngKey.Open(name, provider, CngKeyOpenOptions.UserKey);
}

else
{
try
{
key = CngKey.Create(CngAlgorithm.Rsa, name, new CngKeyCreationParameters
{
KeyCreationOptions = CngKeyCreationOptions.None,
KeyUsage = usages,
Parameters = { new CngProperty("Length", BitConverter.GetBytes(2048), CngPropertyOptions.None) },
Provider = provider
});
}

// If multiple instances of the application were started at the same time, a race condition
// might occur here. In this case, try to open the key that was created by the other instance.
catch (CryptographicException) when (CngKey.Exists(name, provider, CngKeyOpenOptions.UserKey))
{
key = CngKey.Open(name, provider, CngKeyOpenOptions.UserKey);
}
}

return new RsaSecurityKey(new RSACng(key));
}
1
2
3
4
5
6
7
8
9
10
11
services.AddOpenIddict()

// Register the OpenIddict client components.
.AddClient(options =>
{
// ...

// Register the encryption and signing keys.
options.AddEncryptionKey(GetRsaCngKey("Contoso DemoApp encryption key", CngKeyUsages.Decryption));
options.AddSigningKey(GetRsaCngKey("Contoso DemoApp signing key", CngKeyUsages.Signing));
});

On Linux, a new 4096-bit RSA key can be generated using RSA.Create(4096), exported using the RSA.ExportRSAPrivateKey() API introduced in .NET Core 3.0 and written to a file that is only accessible by the current user.

Database

To keep track of the state tokens it produces and offer native protection against token replays, the OpenIddict client requires having access to a database shared by all the instances of your application. If your application already uses MongoDB, Entity Framework 6.4.4+ or Entity Framework Core, you can leverage the corresponding OpenIddict package to easily use your preferred database, as you'd do with ASP.NET Core or OWIN.

If your application doesn't use any of the supported providers, the recommendation is to use Entity Framework Core + SQLite:

1
2
3
4
5
services.AddDbContext<DbContext>(options =>
{
options.UseSqlite("Filename=path-of-your-SQLite-database.sqlite3");
options.UseOpenIddict();
});
1
2
3
4
5
6
7
8
9
10
services.AddOpenIddict()

// Register the OpenIddict core components.
.AddCore(options =>
{
// Configure OpenIddict to use the Entity Framework Core stores and models.
// Note: call ReplaceDefaultEntities() to replace the default OpenIddict entities.
options.UseEntityFrameworkCore()
.UseDbContext<DbContext>();
});

As for the encryption and signing keys, the database MUST be stored in a place that is only accessible by the current user (e.g on Windows, the most common option is to store it in %AppData% alongside your per-user configuration files).

Are samples already available?

Yes! You can find dedicated samples in the openiddict/openiddict-samples repository:

Application modelSupported OS
ConsoleWindows, LinuxMimban.Client
WinFormsWindowsSorgan.WinForms.Client
WPFWindowsSorgan.Wpf.Client
Blazor Hybrid (on WPF)WindowsSorgan.BlazorHybrid.Client

What's next?

Support for additional platforms like iOS, Android, macOS or WASM (for browser-based apps) will depend on community interest, so if you're interested in seeing a specific platform being supported, please add your voice below!