Polhem.OAuth2
1.1.0
dotnet add package Polhem.OAuth2 --version 1.1.0
NuGet\Install-Package Polhem.OAuth2 -Version 1.1.0
<PackageReference Include="Polhem.OAuth2" Version="1.1.0" />
<PackageVersion Include="Polhem.OAuth2" Version="1.1.0" />
<PackageReference Include="Polhem.OAuth2" />
paket add Polhem.OAuth2 --version 1.1.0
#r "nuget: Polhem.OAuth2, 1.1.0"
#:package Polhem.OAuth2@1.1.0
#addin nuget:?package=Polhem.OAuth2&version=1.1.0
#tool nuget:?package=Polhem.OAuth2&version=1.1.0
Polhem.OAuth2
English | 繁體中文
Lightweight OAuth2 sign-in for .NET. Desktop and console applications sign in through the system browser with a loopback
redirect and PKCE, on Windows, macOS and Linux. .NET MAUI applications on Android, iOS and Mac Catalyst sign in through
WebAuthenticator, directly or through their own back end. ASP.NET Core and ASP.NET (System.Web) applications use the
authorization code flow with PKCE, and keep each sign-in in a protected cookie.
Supported providers: Google, Facebook, LINE, Microsoft Entra ID, Auth0 and Okta.
Packages
| Package | Target frameworks | Use it for |
|---|---|---|
| Polhem.OAuth2 | netstandard2.0, net10.0 | The providers, sign-in from desktop, console and .NET MAUI applications, and other server frameworks |
| Polhem.OAuth2.AspNetCore | net10.0 | ASP.NET Core applications |
| Polhem.OAuth2.AspNet | net472 | ASP.NET Web Forms and MVC applications on System.Web |
dotnet add package Polhem.OAuth2
Options
Each provider has its own options type: GoogleOAuth2Options, FacebookOAuth2Options, LineOAuth2Options,
AzureOAuth2Options (Microsoft Entra ID), Auth0OAuth2Options and OktaOAuth2Options.
ClientIdandRedirectUriare required. A client copies and checks the options when it is created, so later changes to them have no effect, and invalid options throwArgumentExceptionright away.- Auth0 and Okta need
Domain, such asyour-tenant.auth0.com. Okta uses thedefaultauthorization server unlessAuthorizationServerIdnames another one; an empty value selects the org authorization server. - Microsoft Entra ID uses the
commontenant. An application registered for a single tenant setsTenantto the tenant ID or domain name. - Every endpoint must be an absolute
httpsURI. UsePkceistrueby default.
Desktop and console applications
LoopbackOAuth2Client listens on the redirect URI, opens the authorization URL 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}");
else
Console.WriteLine($"The sign-in failed: {result.Exception.Message}");
- The redirect URI must be an
httpURI onlocalhostor a loopback address, and it must be registered with the provider. Port 0 picks a free port for each sign-in, which only works with providers that accept any loopback port. - The client always uses PKCE. A client secret shipped with a desktop application can be extracted, so it is not sent, except to Google.
- A timeout (
Timeout, 5 minutes by default), cancellation, or an error from the provider becomes a failed result. A port that cannot be listened on throwsSocketException, and a missing default browser throwsWin32Exception(PlatformNotSupportedExceptionon iOS, which cannot start a process). - Set
OpenBrowserto open the URL another way, for exampleuri => launcher.LaunchUriAsync(uri)with the launcher of a UI framework. - The snippet uses top-level statements. The OAuthWinForms sample shows the same sign-in in a Windows Forms application on .NET Framework.
- An application that targets .NET Framework 4.7.2 and runs on a machine with FIPS mode enabled needs the setting described under "Before deploying" in the ASP.NET (System.Web) section.
- To sign in to a separate back end as well, see the "Apps with a separate back end" section.
The reasons behind this design, and how each provider handled loopback redirects, are recorded in ADR-004.
Registering a loopback redirect URI
These registrations were tested with each provider on 2026-09-14.
| Provider | Application type | Redirect URI | Port |
|---|---|---|---|
| Desktop app | http://127.0.0.1:0/callback |
Any port was accepted | |
| Microsoft Entra ID | Mobile and desktop applications, registered as http://localhost |
http://localhost:0 |
Ignored |
| Auth0 | Native | http://127.0.0.1:53682/callback |
Must match |
| Okta | Native, with client authentication None and PKCE required | http://localhost:53682/callback |
Must match |
| LINE | LINE Login channel, Callback URL | http://localhost:53682/callback |
Must match |
| Facebook Login, Valid OAuth Redirect URIs | http://localhost:53682/callback |
Register the port you use |
- Google: the tested client had its redirect URIs registered. Google's documentation differs on whether a desktop app needs them, and lists the client secret as optional for installed applications.
- Okta: the authorization server needs an access policy with a rule that allows the authorization code grant. Without one, the sign-in fails with a policy evaluation error.
- Facebook refused
127.0.0.1; uselocalhost. Whether the tested app was in development or live mode was not recorded. - A redirect URI without a port means port 80, which usually needs administrator rights to listen on. Name the port.
.NET MAUI applications
An application on Android, iOS or Mac Catalyst signs in in one of two ways (ADR-006):
- Directly, for an application without a back end of its own.
AppOAuth2Clientopens the sign-in withWebAuthenticator, the provider redirects to a URI scheme of the application, and the client exchanges the code with PKCE and without a client secret. - Through the back end, for an application that signs in to its own ASP.NET Core back end. The back end signs in as a web client and gives the application a single-use code, which the application redeems for the user information. Google and LINE accept no direct redirect to an Android application, so on Android they need this way.
WebAuthenticator does not work on Windows, so the Windows platform of a MAUI application signs in with
LoopbackOAuth2Client, as a desktop application does.
Signing in directly
using Polhem.OAuth2;
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();
- The redirect URI is a custom scheme or an
httpsURI.http,javascript,dataandfileURIs, relative URIs, and URIs with a fragment are rejected. - Each provider needs its own redirect form and registration, such as
fb<app id>://authorizefor Facebook andline3rdp.<bundle id>://authfor LINE on iOS. The table in ADR-006 lists them with the platforms tested. - Set no client secret: anything shipped with an application can be extracted.
- Closing the sign-in becomes a failed result with
OperationCanceledException, and an error from the provider, such asaccess_denied, one withOAuth2Exception. - The user information stays in the application. It is no proof of identity for a server, so an application that signs in to its own back end signs in through the back end.
Platform settings
The application must receive the redirect to its scheme:
- iOS and Mac Catalyst: list the scheme under
CFBundleURLTypesinInfo.plist. A Mac Catalyst application in the App Sandbox also needs thecom.apple.security.network.cliententitlement. - Android: add an activity that derives from
WebAuthenticatorCallbackActivity, with an intent filter for the scheme, and declare the Custom Tabs service under<queries>inAndroidManifest.xml, which Android 11 and later require to open it.
[Activity(NoHistory = true, LaunchMode = LaunchMode.SingleTop, Exported = true)]
[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataScheme = "com.example.app")]
public class CallbackActivity : WebAuthenticatorCallbackActivity
{
}
<queries>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
</queries>
Signing in through the back end
The back end registers its web clients as in the ASP.NET Core section, and the redirect URIs of its applications:
builder.Services.AddOAuth2AppRelay(options => options.AppRedirectUris.Add("com.example.app:/relay"));
- The application creates a PKCE code verifier and opens a URL of the back end with its redirect URI and the S256 code
challenge. That endpoint calls
oauth2Manager.RedirectToAppAuthorization(HttpContext, "Google", redirectUri, codeChallenge). - The provider returns to the back end's usual callback. After
CompleteAuthorizationAsync,oauth2Manager.RedirectToAppAsync(HttpContext, result, cancellationToken)sends a sign-in that an application started back to the application with a single-use code and returns true. For a sign-in in the browser it returns false. - The application posts the code and its verifier to the back end, where
oauth2Manager.RedeemAppCodeAsyncreturns the user information, or null. The back end then issues the application's own session; the provider's tokens stay on the back end. - The codes are kept in
IDistributedCache. With several servers, use a distributed cache and share the data protection keys, as for web sign-ins. - The OAuthAspNetCore and OAuthMaui samples show both sides.
ASP.NET Core
using Polhem.OAuth2;
builder.Services.AddControllers();
builder.Services.AddOAuth2Client("Google", new GoogleOAuth2Options
{
ClientId = "your-client-id",
ClientSecret = "your-client-secret",
RedirectUri = "https://localhost:7032/auth/callback"
});
var app = builder.Build();
app.MapControllers();
using Microsoft.AspNetCore.Mvc;
using Polhem.OAuth2;
using Polhem.OAuth2.AspNetCore;
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($"{result.UserInfo.UserId} {result.UserInfo.UserName} {result.UserInfo.Email}")
: Content($"The sign-in failed: {result.Exception.Message}");
}
}
AddOAuth2Clientregisters the client,OAuth2Managerand ASP.NET Core data protection. The client is created by the call, so invalid options stop the application at startup. It takes an optionalHttpClient.oauth2Manager.GetClient("Google")returns the client, for example to callRefreshTokenAsync.
ASP.NET (System.Web)
There is no runnable sample for System.Web, because its projects cannot be built with the dotnet CLI. The package is
used the same way, through the static OAuth2Manager:
using Polhem.OAuth2;
using Polhem.OAuth2.AspNet;
// Global.asax.cs
protected void Application_Start()
{
OAuth2Manager.RegisterClient("Google", new GoogleOAuth2Options
{
ClientId = "your-client-id",
ClientSecret = "your-client-secret",
RedirectUri = "https://localhost:44300/auth/callback"
});
}
// An MVC controller. In Web Forms, call OAuth2Manager.RedirectToAuthorization("Google") from the sign-in page and return,
// and await OAuth2Manager.CompleteAuthorizationAsync() on the callback page, which needs Async="true".
public class AuthController : Controller
{
public ActionResult Login()
{
return Redirect(OAuth2Manager.CreateAuthorizationUrl(HttpContext, "Google"));
}
public async Task<ActionResult> Callback()
{
AuthorizationResult result = await OAuth2Manager.CompleteAuthorizationAsync(HttpContext);
if (result.IsSuccess)
return Content(result.UserInfo.UserId + " " + result.UserInfo.UserName + " " + result.UserInfo.Email);
return Content("The sign-in failed: " + result.Exception.Message);
}
}
Before deploying:
- Target .NET Framework 4.7.2 or later, and set
<httpRuntime targetFramework="4.7.2" />, or your later version, inweb.config. Asynchronous pages and the operating system's TLS defaults depend on it. - On .NET Framework the core package depends on System.Text.Json. Keep the binding redirects that NuGet adds for it and its
dependencies in
web.config. - When more than one server can receive the callback, set the same
<machineKey>inweb.configon each of them. - On a machine with FIPS mode enabled, an application that targets .NET Framework 4.7.2 can get a
CryptographicExceptionwhen a sign-in starts: for such applications .NET Framework blocks the managed SHA-256 implementation that PKCE uses. Target .NET Framework 4.8 or later, or set theSwitch.System.Security.Cryptography.UseLegacyFipsThrowswitch tofalse. See Managed cryptography classes do not throw a CryptographyException in FIPS mode.
Web applications: how a sign-in is kept
- Each sign-in keeps its state, PKCE code verifier, redirect URI and client name in a cookie of its own, encrypted and
authenticated with ASP.NET Core data protection or
MachineKey. No session state is needed, and sign-ins started in several tabs do not replace each other. See ADR-005. - The cookie name starts with
__Host-, and the cookie isSecure, HTTP-only andSameSite=Lax, so the sign-in must start and end on HTTPS pages. - A sign-in must complete within 10 minutes. The callback removes the cookie before it exchanges the code.
- Every server that can receive the callback must be able to decrypt the cookie: share the data protection key ring in ASP.NET Core, or use the same machine key on System.Web.
Other server frameworks
OAuth2Client in the core package runs the same flow without an HTTP framework. Keep the pending values where only the
browser that started the sign-in can present them, such as an encrypted, HTTP-only cookie, and remove them when the
callback is handled.
var client = new OAuth2Client(options);
// Start the sign-in. Keep and Redirect stand for code of your framework.
AuthorizationRequest request = client.CreateAuthorizationRequest();
Keep(request.Pending.State, request.Pending.CodeVerifier, request.Pending.RedirectUri);
Redirect(request.Url);
// Complete it in the callback.
var pending = new PendingAuthorization(keptState, keptCodeVerifier, keptRedirectUri);
var callback = new AuthorizationCallback(query["code"], query["state"], query["error"], query["error_description"]);
AuthorizationResult result = await client.CompleteAuthorizationAsync(callback, pending, cancellationToken);
Results, tokens and errors
- A successful result has
ProviderName,UserInfoandToken; a failed result hasException. Tokenholds the access token, and the refresh token, ID token, lifetime and scopes when the provider returns them.RefreshTokenAsyncon a client obtains new tokens. Keep the refresh token of the latest response, because some providers issue a new one each time. Facebook does not issue refresh tokens.Exceptionholds only the failures a sign-in is expected to produce, such as a failed HTTP request, a state that does not match, or an error from the provider. Configuration and programming errors, such as an unregistered client name, are thrown. See ADR-003.- An error from the provider is an
OAuth2Exception:Errorholds the error code, such asaccess_denied, andErrorDescriptionthe provider's text. Anyone who sends the user a link can set the values of an error in a redirect, so encode them before showing them.
Identifying users
- Identify a user by the provider name together with
UserInfo.UserId, not byEmail: an address can change, and providers differ in whether they verify it. - The provider name is
AuthorizationResult.ProviderName:Google,Facebook,LINE,Azure(Microsoft Entra ID),Auth0orOkta. It does not depend on the name a client is registered under. - For Microsoft Entra ID,
UserIdis thesubclaim, which is different for each application the user signs in to. - LINE returns the email address only in the ID token, and only when the channel may read it and the user agreed. The library reads it from the ID token that the token endpoint returned, and checks that the token was issued to the client, but does not check its signature.
- The library does not validate ID tokens.
Token.IdTokenis returned as the provider sent it; validate it before relying on its claims.
Apps with a separate back end
For brevity, the desktop example signs in and reads the user information in one call, which suits an application that uses the result itself. When the front end signs in and then signs in to a separate back end, do not send the user information from the front end to the back end as proof of identity: the back end cannot tell whether it was forged.
- After signing in, the front end passes the token to the back end over HTTPS, and the back end obtains the user information from the provider with that token.
- Before the back end trusts the token, it confirms that the token was issued to its own client ID, for example through the provider's token verification endpoint or by validating the signature and audience of the ID token. A request to the user information endpoint alone does not confirm this: a token that another application obtained for the same user returns the same user.
- The library does not provide these back-end steps. A .NET MAUI application can sign in through its ASP.NET Core back end instead; see the .NET MAUI section.
Migrating from Bee.OAuth2
| Bee.OAuth2 package | Replacement |
|---|---|
Bee.OAuth2 |
Polhem.OAuth2 |
Bee.OAuth2.AspNet |
Polhem.OAuth2.AspNet |
Bee.OAuth2.AspNetCore |
Polhem.OAuth2.AspNetCore |
Bee.OAuth2.WinForms, Bee.OAuth2.Desktop |
LoopbackOAuth2Client in Polhem.OAuth2 |
- Namespaces:
Bee.OAuth2becomesPolhem.OAuth2, and so on for each package. - Web registration: ASP.NET Core registers each client with
AddOAuth2Client, and System.Web withOAuth2Manager.RegisterClient(name, options). Session state andOAUTH2_STATE_KEYare no longer used. - Web methods:
GetAuthorizationUrlbecomesCreateAuthorizationUrl, andValidateAuthorizationbecomesCompleteAuthorizationAsync. In ASP.NET Core they take theHttpContext. A sign-in started before the upgrade does not complete after it; the user signs in again. - Desktop sign-in moves from an embedded WebView2 window to the system browser. The synchronous
Authorization(),Caption,Width,Height,AuthorizationForm, and the desktopOAuth2Client,OAuth2ManagerandStateStorageare gone; callLoopbackOAuth2Client.SignInAsyncinstead. - Redirect URIs for desktop applications must be registered again as loopback URIs (see the table above). URIs that
only worked inside an embedded browser, such as
https://login.microsoftonline.com/common/oauth2/nativeclient, no longer work. - Results and tokens:
AuthorizationResultis read-only.AccessTokenbecomesToken.AccessToken, and refreshing moves toRefreshTokenAsyncon the client, which returns aTokenResponse. - Types that are no longer public: the provider classes,
IOAuth2Provider,BaseOAuth2Client,IStateStorage,PkceHelperandOAuth2StateCryptor. Applications use an options type with a client or a manager. - PKCE and the client secret:
UsePkceistrueby default. A web client sends the client secret whenever it is set, also with PKCE. - Endpoints must be
https.Domainof Auth0 and Okta takes a host name, with or withouthttps://. - Exceptions:
AuthorizationResult.Exceptionno longer collects unexpected exceptions; they are thrown. - JSON null: a user information field whose value is JSON null is now
null; Bee.OAuth2 returned an empty string. Fallback fields therefore take effect, such asnicknamewhen Auth0 returnsnameas JSON null. - Target frameworks:
Polhem.OAuth2.AspNettargets .NET Framework 4.7.2, andPolhem.OAuth2.AspNetCoretargets net10.0. - Dependencies: no
Bee.BaseorNewtonsoft.Json. JSON is parsed with System.Text.Json, which the netstandard2.0 build references as a package.
Samples
The samples and the loopback redirect probe read their provider settings from one OAuthConfig.json in the repository
root. Copy OAuthConfig.example.json to OAuthConfig.json and fill it in; the build copies the file to the output
folder of each project. OAuthConfig.json is ignored by git; keep credentials out of OAuthConfig.example.json.
Every provider holds one client per client type, because a provider registers desktop, web and mobile clients separately:
{
"Providers": {
"Okta": {
"Domain": "",
"Desktop": { "ClientId": "", "RedirectUri": "http://localhost:53682/callback" },
"Web": { "ClientId": "", "ClientSecret": "", "RedirectUri": "https://localhost:7032/auth/callback" }
}
}
}
- The console and Windows Forms samples and the probe read
Desktop, and the ASP.NET Core sample readsWeb. A client section takes the properties of that provider's options type, such asScopesandUsePkce, and what it leaves out keeps the default of the type. - The fields both clients share, none of which are credentials, sit in the provider section:
Domainfor Auth0 and Okta,AuthorizationServerIdfor Okta, andTenantfor Azure. Anything else there is an error, so credentials cannot end up shared by accident. - A provider whose back end registers one client for both, such as a LINE channel with two callback URLs, gets the same
ClientIdandClientSecretin both sections. - A section whose
ClientIdis still empty is skipped, so the samples offer only the providers you filled in. - The OAuthMaui sample reads
iOSon iOS and Mac Catalyst,Androidon Android, andDesktopon Windows. AniOSorAndroidsection cannot hold aClientSecret, because an application cannot keep one: loading such a file fails. The build of OAuthMaui packages onlyClientId,RedirectUri,Scopes,UsePkceand the shared fields, never a secret. Google and LINE refuse a direct redirect to an Android application (ADR-006), so they have noAndroidsection, and the sample signs in to them through the back end there. - The top-level
AppRelaysection configures the back-end relay:BackendUrlis where the ASP.NET Core sample runs, andRedirectUriis the relay callback of the application, which the ASP.NET Core sample registers withAddOAuth2AppRelay. The relay runs over HTTPS, so the simulator or emulator must trust the ASP.NET Core development certificate. On the Android emulator, runadb reverse tcp:7032 tcp:7032so thatlocalhostreaches the host.
| Sample | Shows |
|---|---|
| OAuthConsole | Desktop sign-in from a console application, on any operating system |
| OAuthDesktop | Desktop sign-in from Windows Forms on .NET |
| OAuthWinForms | Desktop sign-in from Windows Forms on .NET Framework 4.8 |
| OAuthAspNetCore | ASP.NET Core, including the relay endpoints for OAuthMaui |
| OAuthMaui | .NET MAUI: direct and back-end relay sign-in on Android, iOS and Mac Catalyst, loopback sign-in on Windows. It needs the MAUI workload and is not part of the solution. |
LoopbackRedirectProbe checks whether a provider accepts a loopback redirect URI before you build on it.
Design decisions
The reasons behind the design are recorded in the architecture decision records.
License
MIT. Copyright (c) Polhem contributors.
Polhem.OAuth2 continues Bee.OAuth2.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- System.Text.Json (>= 10.0.12)
-
net10.0
- No dependencies.
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Polhem.OAuth2:
| Package | Downloads |
|---|---|
|
Polhem.OAuth2.AspNetCore
OAuth2 sign-in for ASP.NET Core applications, with Google, Facebook, LINE, Microsoft Entra ID, Auth0 and Okta. |
|
|
Polhem.OAuth2.AspNet
OAuth2 sign-in for ASP.NET Web Forms and MVC applications on System.Web, with Google, Facebook, LINE, Microsoft Entra ID, Auth0 and Okta. |
GitHub repositories
This package is not used by any popular GitHub repositories.