Tooark.Securities.OpenId 4.7.0

dotnet add package Tooark.Securities.OpenId --version 4.7.0
                    
NuGet\Install-Package Tooark.Securities.OpenId -Version 4.7.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Tooark.Securities.OpenId" Version="4.7.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Tooark.Securities.OpenId" Version="4.7.0" />
                    
Directory.Packages.props
<PackageReference Include="Tooark.Securities.OpenId" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Tooark.Securities.OpenId --version 4.7.0
                    
#r "nuget: Tooark.Securities.OpenId, 4.7.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Tooark.Securities.OpenId@4.7.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Tooark.Securities.OpenId&version=4.7.0
                    
Install as a Cake Addin
#tool nuget:?package=Tooark.Securities.OpenId&version=4.7.0
                    
Install as a Cake Tool

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.

🌍 Languages: πŸ‡ΊπŸ‡Έ English (this file) Β· πŸ‡§πŸ‡· PortuguΓͺs


πŸ“‘ Contents


πŸ“– 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:

  • code flow with PKCE β€” the ASP.NET Core handler is born in id_token (implicit flow, abandoned by OAuth 2.1); the package pins authorization code and turns PKCE on.
  • Claims with their OpenID Connect names β€” MapInboundClaims = false: sub, email and name arrive as they are, without becoming ClaimTypes.NameIdentifier and 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 ValidIssuers add to the issuer of the discovery document, which the handler always accepts. ValidAudiences in the interactive handler only complements the ClientId, which is the audience of the id_token by 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 explicit Authority takes 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 without requestedAccessTokenVersion: 2 receive v1 tokens β€” and the v1 issuer only matches when TenantId is the GUID.
  • ValidAudiences (Bearer) = {ClientId} and api://{ClientId}, the default Application ID URI. A custom URI must be provided in ValidAudiences.
  • Multi-tenant (TenantId = common, organizations or consumers): the discovery document publishes an issuer with the {tenantid} placeholder, which never matches; the preset requires ValidIssuers with the issuers of the accepted tenants and fails at startup without them.
  • ClientSecret is 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.com and accounts.google.com β€” Google uses both forms.
  • HostedDomain: sent as the hd parameter on the login screen and checked against the token's hd claim. The hint on the screen restricts nothing by itself; accounts from another domain and personal accounts (without the claim) are rejected with OpenId.HostedDomain.Invalid. The check is chained after the consumer callback, so it survives ConfigureOpenIdConnect/ConfigureJwtBearer even when they replace Events.
  • ClientSecret is required for SSO.
  • Bearer: validates Google's id_token (JWT with aud = 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);

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

  1. One registration per application at the provider β€” site and API must have their own ClientId; the BFF above uses two.
  2. Secrets out of the repository β€” Secret Manager in development, a vault or environment variables in production (OpenId__Entra__ClientSecret).
  3. 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.
  4. Check hd in Google β€” HostedDomain does that; without it, any Google account gets in.
  5. Do not turn on SaveTokens without need β€” the tokens go into the cookie; turn it on only when the application calls other APIs on behalf of the user.
  6. Keep MapInboundClaims = false β€” new code reads sub, email, name; turn it on only for legacy code tied to ClaimTypes.*.
  7. RequireHttpsMetadata = false only in development β€” and never in the versioned appsettings.json; use appsettings.Development.json.
  8. 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:

Every contribution helps keep the project maintained and improving. Thank you! πŸ™


πŸ“„ License

This project is licensed under the BSD 3-Clause License.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

Version Downloads Last Updated
4.7.0 99 9/30/2026
4.6.0 131 9/29/2026
4.5.0 101 9/29/2026
4.4.0 106 9/28/2026
4.3.0 119 9/26/2026
4.2.1 110 9/25/2026
4.2.0 121 9/20/2026