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.1orlocalhoston the local machine), and uses PKCE. Runs on Windows, macOS and Linux. - .NET MAUI apps on Android, iOS and Mac Catalyst: signs in through
WebAuthenticatorwith 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
OAuth2Clientruns 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}");
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"
});
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}");
}
}
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();
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.WinFormsandBee.OAuth2.Desktopare replaced byLoopbackOAuth2Clientin 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_KEYenvironment variable. Each sign-in has its own cookie, protected by ASP.NET Core Data Protection orMachineKey. - One-line registration in ASP.NET Core:
AddOAuth2Clientreplaces theOAuth2Managersingleton you used to assemble by hand. - Okta and .NET MAUI support are new.
- No more dependency on
Bee.BaseorNewtonsoft.Json. JSON is now parsed withSystem.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
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
- RFC 7636: Proof Key for Code Exchange: the PKCE specification
- RFC 8252: OAuth 2.0 for Native Apps: why desktop and mobile apps should use the system browser
📘 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
Top comments (0)