Tooark.Securities.OpenId
4.7.1
dotnet add package Tooark.Securities.OpenId --version 4.7.1
NuGet\Install-Package Tooark.Securities.OpenId -Version 4.7.1
<PackageReference Include="Tooark.Securities.OpenId" Version="4.7.1" />
<PackageVersion Include="Tooark.Securities.OpenId" Version="4.7.1" />
<PackageReference Include="Tooark.Securities.OpenId" />
paket add Tooark.Securities.OpenId --version 4.7.1
#r "nuget: Tooark.Securities.OpenId, 4.7.1"
#:package Tooark.Securities.OpenId@4.7.1
#addin nuget:?package=Tooark.Securities.OpenId&version=4.7.1
#tool nuget:?package=Tooark.Securities.OpenId&version=4.7.1
Tooark.Securities.OpenId
Library that standardizes how Tooark projects configure OpenID Connect authentication in ASP.NET Core, with SSO presets for Microsoft Entra ID and Google and a generic path for any OIDC provider (Keycloak, Auth0, Okta...).
The package configures the native handlers of ASP.NET Core β OpenIdConnect, JwtBearer and cookie β and
implements no protocol flow. There is no provider SDK, no authorization server, no code issuance and no PKCE
verification of its own: that territory belongs to OpenIddict, Duende and Keycloak. The value is in the
configuration standard: code flow with PKCE, token validation and claim mapping that are the same across
every project.
π Docs: Tooark.Securities.OpenId on the site Β· All packages Β· API reference
π Languages: πΊπΈ English (this file) Β· π§π· PortuguΓͺs
π Contents
- Overview
- Installation
- Configuration
- Presets
- Generic provider and custom presets
- Components
- Usage Examples
- Dependencies
- Best Practices
- Error Codes and Solutions
- Contributing
- Help & Security
- Support
- License
π Overview
Each provider is read from the OpenId:{name} subsection of appsettings.json, and the name becomes the
authentication scheme registered in ASP.NET Core. There are two handlers per provider:
| Method | Handler | Usage |
|---|---|---|
AddTooark{Provider}Sso |
OpenIdConnect |
Interactive browser login, with the session in a cookie |
AddTooark{Provider}Bearer |
JwtBearer |
API that validates the token issued by the provider |
The Tooark defaults applied to the handlers:
codeflow with PKCE β the ASP.NET Core handler is born inid_token(implicit flow, abandoned by OAuth 2.1); the package pinsauthorization codeand turns PKCE on.- Claims with their OpenID Connect names β
MapInboundClaims = false:sub,emailandnamearrive as they are, without becomingClaimTypes.NameIdentifierand the like. - Issuer, audience and lifetime validation always on, with a configurable clock skew.
- HTTPS required for the discovery document, unless explicitly relaxed for development.
- Fail at startup: missing
ClientId,Authority/tenant and secret, invalid addresses and a multi-tenant tenant without issuers are reported on registration, not on the first login.
π§ Installation
dotnet add package Tooark.Securities.OpenId
βοΈ Configuration
appsettings.json
Every provider lives under OpenId, one per subsection. The presets have a default name (Entra, Google);
the generic provider uses the name you pass on registration.
{
"OpenId": {
"Entra": {
"TenantId": "11111111-2222-3333-4444-555555555555",
"ClientId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"ClientSecret": "secret-from-the-entra-registration"
},
"Google": {
"ClientId": "1234567890-abc.apps.googleusercontent.com",
"ClientSecret": "secret-from-the-google-console",
"HostedDomain": "tooark.com"
},
"Keycloak": {
"Authority": "https://sso.example.com/realms/tooark",
"ClientId": "app-web",
"ClientSecret": "client-secret"
}
}
}
Secrets go to the Secret Manager, environment variables or a vault β never to the repository. The equivalent environment key is
OpenId__Entra__ClientSecret.
Program.cs
using Tooark.Securities.OpenId.Injections;
var builder = WebApplication.CreateBuilder(args);
// Login with Entra ID (web application)
builder.Services.AddTooarkEntraSso(builder.Configuration);
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
Every method accepts the provider name and a programmatic configuration action, applied on top of
appsettings.json:
builder.Services.AddTooarkEntraSso(builder.Configuration, "EntraCorp", options =>
{
options.Scopes = ["openid", "profile", "email", "User.Read"];
options.Prompt = "select_account";
});
OpenIdOptions properties
Apply to the presets and to the generic provider. The SSO and Bearer columns show in which handler the property takes effect.
| Property | Type | Default | SSO | Bearer | Description |
|---|---|---|---|---|---|
Authority |
string? | null |
β | β | Issuer address; discovery is read from /.well-known/openid-configuration |
MetadataAddress |
string? | null |
β | β | Explicit address of the discovery document, when outside the default path |
ClientId |
string? | null |
β | β | Application identifier at the provider. Required |
ClientSecret |
string? | null |
β | Application secret. Required in the presets; optional in the generic one (PKCE) | |
Scopes |
string[]? | openid profile email |
β | Requested scopes. openid is always included |
|
UsePkce |
bool | true |
β | PKCE on the code exchange. Turn off only for providers without support | |
CallbackPath |
string | /signin-oidc |
β | Redirect URI registered at the provider: https://{host}/signin-oidc |
|
SignedOutCallbackPath |
string | /signout-callback-oidc |
β | Return after logging out at the provider | |
SignInScheme |
string? | null |
β | Session scheme. Null registers the default cookie (Cookies) once |
|
SaveTokens |
bool | false |
β | β | Keeps the tokens in the session (bigger cookie). Turn on only to call other APIs |
GetClaimsFromUserInfoEndpoint |
bool | false |
β | Complements the claims by querying the userinfo endpoint | |
RequireHttpsMetadata |
bool | true |
β | β | Rejects http:// in Authority/MetadataAddress. Turn off only in development |
MapInboundClaims |
bool | false |
β | β | Renames claims to the legacy ClaimTypes.* types. Turn on only for legacy code |
NameClaimType |
string | name |
β | β | Claim of User.Identity.Name. Presets: preferred_username (Entra), email (Google) |
RoleClaimType |
string | roles |
β | β | Claim of User.IsInRole |
ValidIssuers |
string[]? | null |
β | β | Accepted issuers, besides the one published in discovery. Presets derive their own |
ValidAudiences |
string[]? | null |
β | β | Accepted audiences. Bearer: default ClientId; Entra adds api://{ClientId} |
ClockSkewSeconds |
int | 300 |
β | β | Clock skew tolerance in lifetime validation |
Prompt |
string? | null |
β | prompt parameter of the login screen (login, select_account...) |
|
DisplayName |
string? | scheme name | β | Name in the external login provider lists | |
ConfigureOpenIdConnect |
Action? | null |
β | Final callback over OpenIdConnectOptions β the consumer has the last word |
|
ConfigureJwtBearer |
Action? | null |
β | Final callback over JwtBearerOptions β the consumer has the last word |
The issuers in
ValidIssuersadd to the issuer of the discovery document, which the handler always accepts.ValidAudiencesin the interactive handler only complements theClientId, which is the audience of theid_tokenby definition.
Default schemes
The first registered provider sets the ASP.NET Core default schemes, and only when the application has not set them yet:
| Method | DefaultScheme |
DefaultChallengeScheme |
|---|---|---|
*Sso |
cookie (SignInScheme or Cookies) |
the provider |
*Bearer |
the provider | β |
For mixed scenarios (site + API in the same application), register both with different names and point to the
scheme in the API endpoints with [Authorize(AuthenticationSchemes = "EntraApi")], or set the defaults
explicitly in AddAuthentication(options => ...) β whatever the application sets is preserved.
π Presets
Microsoft Entra ID β EntraOptions
| Property | Type | Default | Description |
|---|---|---|---|
TenantId |
string? | null |
Tenant GUID or domain. Required |
Instance |
string | https://login.microsoftonline.com/ |
Change only for sovereign clouds (login.microsoftonline.us...) |
Preset derivations:
Authority={Instance}{TenantId}/v2.0(an explicitAuthoritytakes precedence).NameClaimType=preferred_username(UPN/login email);RoleClaimType=roles(app roles).ValidIssuers= the tenant's v2 issuer and the v1 issuer (https://sts.windows.net/{tenant}/), in the public cloud. APIs registered withoutrequestedAccessTokenVersion: 2receive v1 tokens β and the v1 issuer only matches whenTenantIdis the GUID.ValidAudiences(Bearer) ={ClientId}andapi://{ClientId}, the default Application ID URI. A custom URI must be provided inValidAudiences.- Multi-tenant (
TenantId=common,organizationsorconsumers): the discovery document publishes an issuer with the{tenantid}placeholder, which never matches; the preset requiresValidIssuerswith the issuers of the accepted tenants and fails at startup without them. ClientSecretis required for SSO: Entra does not exchange the code of a web application without the secret (AADSTS7000218).
Out of scope: Graph API, distributed token cache and the on-behalf-of flow require Microsoft.Identity.Web.
The criterion for a package of its own is "requires the provider SDK" β in that case, a Tooark.Securities.Entra
satellite.
Google β GoogleOptions
| Property | Type | Default | Description |
|---|---|---|---|
HostedDomain |
string? | null |
Google Workspace domain the login is restricted to (e.g. tooark.com) |
Preset derivations:
Authority=https://accounts.google.com.NameClaimType=email.ValidIssuers=https://accounts.google.comandaccounts.google.comβ Google uses both forms.HostedDomain: sent as thehdparameter on the login screen and checked against the token'shdclaim. The hint on the screen restricts nothing by itself; accounts from another domain and personal accounts (without the claim) are rejected withOpenId.HostedDomain.Invalid. The check is chained after the consumer callback, so it survivesConfigureOpenIdConnect/ConfigureJwtBearereven when they replaceEvents.ClientSecretis required for SSO.- Bearer: validates Google's
id_token(JWT withaud=ClientId). Google's access token is opaque and does not go through this handler.
π§© Generic provider and custom presets
For any OpenID Connect provider, provide the Authority and register by name:
builder.Services.AddTooarkOpenIdSso(builder.Configuration, "Keycloak");
builder.Services.AddTooarkOpenIdBearer(builder.Configuration, "Keycloak");
For a provider with its own conventions, inherit from OpenIdOptions and override what you need β that is
exactly how EntraOptions and GoogleOptions are built:
public class KeycloakOptions : OpenIdOptions
{
public string? BaseUrl { get; set; }
public string? Realm { get; set; }
// Derives the Authority when not provided
public override string? Authority
{
get => base.Authority ?? (Realm is null ? null : $"{BaseUrl}/realms/{Realm}");
set => base.Authority = value;
}
// Own validations, besides the common ones
public override void Validate(bool interactive)
{
if (string.IsNullOrWhiteSpace(Realm))
{
throw new InternalServerErrorException("Options.OpenId.Keycloak.RealmNotConfigured");
}
base.Validate(interactive);
}
// Extra behavior on the handler (the consumer callback already ran inside base)
public override void ConfigureHandler(OpenIdConnectOptions target)
{
base.ConfigureHandler(target);
target.ClaimActions.MapJsonKey("realm_roles", "realm_access.roles");
}
}
builder.Services.AddTooarkOpenIdSso<KeycloakOptions>(builder.Configuration, "Keycloak");
Available virtual members: Authority, ResolveValidIssuers(), ResolveValidAudiences(),
Validate(bool interactive), ConfigureHandler(OpenIdConnectOptions) and ConfigureHandler(JwtBearerOptions).
π¦ Components
Dependency Injection Extensions
| Method | Handler | Default section | Default scheme |
|---|---|---|---|
AddTooarkEntraSso() |
OpenIdConnect |
OpenId:Entra |
Entra |
AddTooarkEntraBearer() |
JwtBearer |
OpenId:Entra |
Entra |
AddTooarkGoogleSso() |
OpenIdConnect |
OpenId:Google |
Google |
AddTooarkGoogleBearer() |
JwtBearer |
OpenId:Google |
Google |
AddTooarkOpenIdSso() |
OpenIdConnect |
OpenId:{name} |
{name} |
AddTooarkOpenIdBearer() |
JwtBearer |
OpenId:{name} |
{name} |
AddTooarkOpenIdSso<TOptions>() |
OpenIdConnect |
OpenId:{name} |
{name} |
AddTooarkOpenIdBearer<TOptions>() |
JwtBearer |
OpenId:{name} |
{name} |
Common signature: (IConfiguration configuration, string name, Action<TOptions>? configure = null) β in the
presets, name has a default value. All of them return IServiceCollection.
Options
| Class | Description |
|---|---|
OpenIdOptions |
Options of a generic OpenID Connect provider; base of the presets |
EntraOptions |
Microsoft Entra ID preset: TenantId, Instance and the tenant derivations |
GoogleOptions |
Google preset: HostedDomain and Google's conventions |
π Usage Examples
Web application with corporate login (Entra)
builder.Services.AddTooarkEntraSso(builder.Configuration);
builder.Services.AddAuthorization();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/profile", (ClaimsPrincipal user) => new
{
Name = user.Identity?.Name, // preferred_username
Id = user.FindFirst("oid")?.Value, // user id in the tenant
Roles = user.FindAll("roles").Select(claim => claim.Value)
}).RequireAuthorization();
app.MapGet("/login", () => Results.Challenge(
new AuthenticationProperties { RedirectUri = "/" },
["Entra"]
));
app.MapGet("/logout", () => Results.SignOut(
new AuthenticationProperties { RedirectUri = "/" },
[CookieAuthenticationDefaults.AuthenticationScheme, "Entra"]
));
Two providers in the same application
The providers share the same cookie; the first registered is the default challenge. The login screen picks the provider by scheme:
builder.Services.AddTooarkEntraSso(builder.Configuration);
builder.Services.AddTooarkGoogleSso(builder.Configuration);
app.MapGet("/login/{provider}", (string provider) => Results.Challenge(
new AuthenticationProperties { RedirectUri = "/" },
[provider] // "Entra" or "Google"
));
API protected by Entra tokens
builder.Services.AddTooarkEntraBearer(builder.Configuration);
builder.Services.AddAuthorization();
app.MapGet("/api/orders", () => Results.Ok()).RequireAuthorization();
Site and API in the same application (BFF)
builder.Services.AddTooarkEntraSso(builder.Configuration); // OpenId:Entra β cookie + challenge
builder.Services.AddTooarkEntraBearer(builder.Configuration, "EntraApi"); // OpenId:EntraApi β API
app.MapGet("/api/orders", () => Results.Ok())
.RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = "EntraApi" });
Google restricted to the company domain
{
"OpenId": {
"Google": {
"ClientId": "1234567890-abc.apps.googleusercontent.com",
"ClientSecret": "secret",
"HostedDomain": "tooark.com"
}
}
}
builder.Services.AddTooarkGoogleSso(builder.Configuration);
Custom cookie
Register the cookie first and provide SignInScheme; the package stops registering the default cookie:
builder.Services.AddAuthentication().AddCookie("AppCookie", options =>
{
options.LoginPath = "/login";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
});
builder.Services.AddTooarkEntraSso(builder.Configuration, configure: options => options.SignInScheme = "AppCookie");
Fine-tuning the handler
The callback is the last to run, so it can override any default:
builder.Services.AddTooarkOpenIdSso(builder.Configuration, "Keycloak", options =>
{
options.ConfigureOpenIdConnect = oidc =>
{
oidc.ResponseMode = OpenIdConnectResponseMode.Query;
oidc.Events.OnTokenValidated = context =>
{
// enrich the principal, audit the login...
return Task.CompletedTask;
};
};
});
Development against a local provider
{
"OpenId": {
"Keycloak": {
"Authority": "http://localhost:8080/realms/dev",
"ClientId": "app-web",
"RequireHttpsMetadata": false
}
}
}
Multiple instances or containers
The session cookie, the correlation cookies and the login state are protected by ASP.NET Core Data
Protection. By default each instance has its own keys, and a login that starts on one instance and comes back
through another fails with Correlation failed or Unable to unprotect the message.State. Share the key ring
with AddTooarkDataProtection from
Tooark.Securities:
builder.Services.AddTooarkDataProtection(builder.Configuration); // DataProtection β shared key ring
builder.Services.AddTooarkEntraSso(builder.Configuration); // OpenId:Entra β cookie + challenge
{
"DataProtection": {
"ApplicationName": "app-web",
"RequirePersistentKeyStorage": true,
"KeysPath": "/var/dataprotection/keys",
"CertificatePath": "/var/dataprotection/certificate.pfx",
"CertificatePassword": "certificate-password"
}
}
With RequirePersistentKeyStorage, the application does not start without the key storage. The other risks of
multiple instances are in
Clusters, containers and autoscaling.
π Dependencies
| Package | Version | Description |
|---|---|---|
Tooark.Exceptions |
4.x | Exceptions (InternalServerErrorException) |
Microsoft.AspNetCore.Authentication.OpenIdConnect |
8.x/10.x | ASP.NET Core OpenIdConnect handler |
Microsoft.AspNetCore.Authentication.JwtBearer |
8.x/10.x | ASP.NET Core JwtBearer handler |
The package declares the Microsoft.AspNetCore.App shared framework: it exists to configure the ASP.NET Core
authentication handlers and has no use outside an ASP.NET Core application.
π― Best Practices
- One registration per application at the provider β site and API must have their own
ClientId; the BFF above uses two. - Secrets out of the repository β Secret Manager in development, a vault or environment variables in
production (
OpenId__Entra__ClientSecret). - A specific tenant in Entra β prefer the tenant GUID over
common/organizations; multi-tenant requires listing the accepted issuers and widens the surface of who can authenticate. - Check
hdin Google βHostedDomaindoes that; without it, any Google account gets in. - Do not turn on
SaveTokenswithout need β the tokens go into the cookie; turn it on only when the application calls other APIs on behalf of the user. - Keep
MapInboundClaims = falseβ new code readssub,email,name; turn it on only for legacy code tied toClaimTypes.*. RequireHttpsMetadata = falseonly in development β and never in the versionedappsettings.json; useappsettings.Development.json.- A shared key ring with more than one instance β without it, the login fails when the load balancer switches instances in the middle of the flow; see Multiple instances or containers.
β οΈ Error Codes and Solutions
Configuration errors are InternalServerErrorExceptions thrown on registration (Program.cs). The domain
rejection happens during token validation and results in an authentication failure (401), with no exception.
| Message | Description | Solution |
|---|---|---|
Options.OpenId.NameNotConfigured |
Empty provider name | Provide the name (subsection and scheme) on registration |
Options.OpenId.ClientIdNotConfigured |
Missing ClientId |
Configure ClientId in the provider subsection |
Options.OpenId.ClientSecretNotConfigured |
Missing ClientSecret in a preset's SSO |
Configure ClientSecret; Entra and Google do not exchange the code without it |
Options.OpenId.AuthorityNotConfigured |
Neither Authority nor MetadataAddress |
Configure the provider's Authority |
Options.OpenId.Authority.Invalid;{value} |
Authority is not an absolute URL or uses HTTP |
Use https://; in development, RequireHttpsMetadata = false |
Options.OpenId.MetadataAddress.Invalid;{value} |
MetadataAddress is not an absolute URL or uses HTTP |
Same as above |
Options.OpenId.CallbackPath.Invalid;{value} |
CallbackPath does not start with / |
Use a root-relative path, e.g. /signin-oidc |
Options.OpenId.SignedOutCallbackPath.Invalid;{value} |
SignedOutCallbackPath does not start with / |
Same as above |
Options.OpenId.ClockSkew.Invalid;{value} |
Negative ClockSkewSeconds |
Use zero or a positive value |
Options.OpenId.Entra.TenantIdNotConfigured |
Missing TenantId |
Configure the tenant GUID or domain |
Options.OpenId.Entra.Instance.Invalid;{value} |
Instance is not an absolute HTTPS URL |
Use the cloud instance, e.g. https://login.microsoftonline.com/ |
Options.OpenId.Entra.MultiTenantRequiresValidIssuers;{tenant} |
Multi-tenant tenant without ValidIssuers |
List the issuers of the accepted tenants, or use a tenant GUID |
OpenId.HostedDomain.Invalid |
Token from an account outside HostedDomain |
Sign in with an account of the configured domain |
π€ Contributing
Contributions are welcome! Start with CONTRIBUTING.md β it covers the development workflow, the coding and commit conventions and the pull request checklist. Bugs and feature requests go through the issue templates of the Tooark repository.
By participating you agree to the Code of Conduct.
π Help & Security
- β Questions, bugs, feature ideas β see SUPPORT.md for the right channel
- π Security vulnerabilities β do not open a public issue; follow SECURITY.md
π Support
If Tooark helps your projects, consider supporting its development:
- π GitHub Sponsors
- β Ko-fi
Every contribution helps keep the project maintained and improving. Thank you! π
π License
This project is licensed under the BSD 3-Clause License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. 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. |
-
net10.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.13)
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 10.0.13)
- Tooark.Exceptions (>= 4.7.1)
-
net8.0
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 8.0.32)
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 8.0.32)
- Tooark.Exceptions (>= 4.7.1)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Tooark.Securities.OpenId:
| Package | Downloads |
|---|---|
|
Tooark
Package with all Tooark resources for .NET applications. |
GitHub repositories
This package is not used by any popular GitHub repositories.