One OAuth2 Library for Every Kind of .NET App

One OAuth2 Library for Every Kind of .NET App

# dotnet# oauth# csharp# maui
One OAuth2 Library for Every Kind of .NET AppJeff Tsai

Polhem.OAuth2, the successor to Bee.OAuth2, now covers .NET MAUI as well

Adding "Sign in with Google" to a .NET application looks like a problem that was solved long ago, until you have more than one kind of application. A website uses ASP.NET Core's authentication handlers, a desktop tool needs something that can open a browser and catch the callback, a mobile app does it another way again, and none of these help the old Web Forms site that is still in production.

I wanted a small library that handles all of these in the same way, so I built Polhem.OAuth2. It is open source under the MIT license and available on NuGet.

If you have used Bee.OAuth2, Polhem.OAuth2 is its successor. It keeps the idea of one set of options per provider (the sign-in service, such as Google or LINE), and most of the rest has been rebuilt. Section 6 lists what changed.


1️⃣ What it covers

  • Desktop and console apps: opens the system browser, receives the callback on a loopback address (127.0.0.1 or localhost on the local machine), and uses PKCE. Runs on Windows, macOS and Linux.
  • .NET MAUI apps on Android, iOS and Mac Catalyst: signs in through WebAuthenticator with PKCE, and keeps no client secret in the app. An app with its own ASP.NET Core back end can sign in through that back end instead, so the provider's tokens never reach the device.
  • ASP.NET Core: the authorization code flow with PKCE, with the state of each sign-in kept in its own protected cookie.
  • Classic ASP.NET: the same flow for Web Forms and MVC applications on System.Web.
  • Other server frameworks: the core OAuth2Client runs the flow in two steps and does not depend on any HTTP framework.

PKCE (Proof Key for Code Exchange) is an extension to OAuth2. The client generates a random value when it starts the sign-in and presents it again when it exchanges the code for tokens, so an intercepted authorization code is useless without that value. For desktop and mobile apps, which cannot keep a client secret, it is essential.

Supported providers: Google, Facebook, LINE, Microsoft Entra ID, Auth0 and Okta. Every provider returns the result in the same shape: the user ID, name and email, plus the access, refresh and ID tokens.

Package Target frameworks Use
Polhem.OAuth2 netstandard2.0, net10.0 The providers; desktop, console and .NET MAUI apps; other server frameworks
Polhem.OAuth2.AspNetCore net10.0 ASP.NET Core
Polhem.OAuth2.AspNet net472 ASP.NET Web Forms and MVC on System.Web

2️⃣ Desktop or console apps

LoopbackOAuth2Client listens on the redirect URI, opens the sign-in page in the default browser, waits for the provider to redirect back, and exchanges the authorization code:

using Polhem.OAuth2;

var options = new GoogleOAuth2Options
{
    ClientId = "your-client-id",
    ClientSecret = "your-client-secret",
    RedirectUri = "http://127.0.0.1:0/callback"
};

var client = new LoopbackOAuth2Client(options);
AuthorizationResult result = await client.SignInAsync();

if (result.IsSuccess)
    Console.WriteLine($"{result.UserInfo.UserId} {result.UserInfo.UserName} {result.UserInfo.Email}");
Enter fullscreen mode Exit fullscreen mode

Port 0 picks a free port for each sign-in. This only works with providers that accept any loopback port, such as Google. For other providers, use the port you registered.


3️⃣ ASP.NET Core apps

Register the client at startup:

builder.Services.AddOAuth2Client("Google", new GoogleOAuth2Options
{
    ClientId = "your-client-id",
    ClientSecret = "your-client-secret",
    RedirectUri = "https://localhost:7032/auth/callback"
});
Enter fullscreen mode Exit fullscreen mode

Then start and complete the sign-in with two actions:

public class AuthController(OAuth2Manager oauth2Manager) : ControllerBase
{
    [HttpGet("/auth/login")]
    public IActionResult Login()
    {
        return Redirect(oauth2Manager.CreateAuthorizationUrl(HttpContext, "Google"));
    }

    [HttpGet("/auth/callback")]
    public async Task<IActionResult> Callback()
    {
        AuthorizationResult result = await oauth2Manager.CompleteAuthorizationAsync(HttpContext, HttpContext.RequestAborted);
        return result.IsSuccess
            ? Content($"Hello, {result.UserInfo.UserName}")
            : Content($"The sign-in failed: {result.Exception.Message}");
    }
}
Enter fullscreen mode Exit fullscreen mode

The state, PKCE code verifier and redirect URI of each sign-in are kept in their own cookie, encrypted with ASP.NET Core Data Protection. So no session is needed, sign-ins started in several tabs at once do not overwrite each other, and several servers can work together as long as they share the Data Protection keys.


4️⃣ .NET MAUI apps

Anything inside a mobile app can be extracted, so it cannot keep a client secret. AppOAuth2Client therefore always uses PKCE and never sends a client secret. You pass it a function that opens the browser, which is usually WebAuthenticator:

var options = new Auth0OAuth2Options
{
    Domain = "your-tenant.auth0.com",
    ClientId = "your-native-client-id",
    RedirectUri = "com.example.app:/oauth2redirect"
};

var client = new AppOAuth2Client(options, async (url, redirectUri, cancellationToken) =>
    (await WebAuthenticator.Default.AuthenticateAsync(url, redirectUri)).CallbackUri);

AuthorizationResult result = await client.SignInAsync();
Enter fullscreen mode Exit fullscreen mode

If the app signs in to its own back end, the back end can complete the sign-in as a web client and hand the app a one-time code. The app exchanges that code for the user's information, and the provider's tokens stay on the server.


5️⃣ Design decisions

A few decisions shape how the library behaves:

  • PKCE is on by default, and every provider endpoint must use https.
  • Options are checked when the client is created. Invalid options throw immediately, which in ASP.NET Core means at application startup rather than at the first sign-in.
  • Expected failures are returned in the result, and only programming errors throw. A failed HTTP request, a state mismatch or an error from the provider ends up in result.Exception. An unregistered client name is a programming error and throws.
  • Users are identified by provider plus user ID, not by email. Emails change, and providers differ in whether they verify them.
  • Few dependencies: the net10.0 build of the core package has no package dependencies, the public API has nullable annotations, and the net10.0 build is checked with the trim and AOT analyzers (which confirm it still works when unused code is trimmed at publish and it is compiled ahead of time to native code).

The reasoning behind each decision is written up as an ADR (Architecture Decision Record) in the repo's docs/adr/.


6️⃣ Coming from Bee.OAuth2

  • Five packages became three: Bee.OAuth2.WinForms and Bee.OAuth2.Desktop are replaced by LoopbackOAuth2Client in the core package.
  • Desktop sign-in now uses the system browser instead of an embedded WebView2 window. Users can see the address bar and keep their existing sessions and password manager, and the app cannot see what they type. Google's policy already rejects embedded browsers, and with WebView2 gone, desktop sign-in is no longer tied to Windows.
  • Web sign-in no longer needs a session or the OAUTH2_STATE_KEY environment variable. Each sign-in has its own cookie, protected by ASP.NET Core Data Protection or MachineKey.
  • One-line registration in ASP.NET Core: AddOAuth2Client replaces the OAuth2Manager singleton you used to assemble by hand.
  • Okta and .NET MAUI support are new.
  • No more dependency on Bee.Base or Newtonsoft.Json. JSON is now parsed with System.Text.Json.

The README has a migration guide that maps each old type and method to its replacement.


✅ Wrapping up

Install the core package:

dotnet add package Polhem.OAuth2
Enter fullscreen mode Exit fullscreen mode

The README covers each kind of application, including how to register the redirect URI with each provider. The repo also includes sample apps: a console app, Windows Forms on .NET and on .NET Framework, ASP.NET Core, and .NET MAUI.

The library only handles signing in and getting the user's information and tokens. It does not validate the ID token's signature, and it does not manage accounts or permissions after sign-in. If your project is already well served by ASP.NET Core's built-in authentication or an OpenID Connect package, you may not need to switch. It fits best when you have desktop, mobile and web applications at the same time and want to write sign-in the same way in all of them. Feedback, issues and pull requests are welcome.

Further reading


📘 Original article on HackMD, in Traditional Chinese:
👉 https://hackmd.io/@jeff377/polhem-oauth2

📢 You are welcome to republish this, please credit the source.
📬 Follow for more engineering notes:
Facebook | HackMD | GitHub | NuGet