Centeva.Oidc 1.0.0

Prefix Reserved
dotnet add package Centeva.Oidc --version 1.0.0
                    
NuGet\Install-Package Centeva.Oidc -Version 1.0.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="Centeva.Oidc" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Centeva.Oidc" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Centeva.Oidc" />
                    
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 Centeva.Oidc --version 1.0.0
                    
#r "nuget: Centeva.Oidc, 1.0.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 Centeva.Oidc@1.0.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=Centeva.Oidc&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Centeva.Oidc&version=1.0.0
                    
Install as a Cake Tool

Centeva.Oidc

Reusable ASP.NET Core OIDC/JWT authentication setup in a single extension method.

Configuration is plain OIDC — an authority and an audience. There are no provider-specific packages or options: every endpoint and signing key is discovered from the authority's .well-known/openid-configuration document, so the same code works against Microsoft Entra ID, Keycloak, or any conforming provider, and your application never has to know which one it is talking to.

Packages

Package Purpose
Centeva.Oidc.Abstractions Options and constants (netstandard2.0)
Centeva.Oidc ASP.NET Core integration — JwtBearer + Cookie + OpenIdConnect

Requirements

What a consuming application must provide. Nothing here has a usable default — the package cannot guess any of it, and where it can detect a value is missing it fails at startup rather than at the first request.

Configuration — required in every environment

Option Required Notes
Authority Yes — startup throws without it The provider's OIDC issuer URL. See Authority.
Audience Yes — startup throws without it The identifier in the token's aud claim. See Audience and ClientId.
ClientId Only if you use the Dashboard or Scalar policies The client this application signs in as, server-side.
RequiredScope No — defaults to all The scope that marks a delegated token. Set it to match your provider.
AppOnlyRoles Only if you have service-to-service callers App roles that authorize an app-only caller — any one is sufficient. Empty by default, which disables that path.
RequiredDelegatedClaims No — defaults to sub, given_name, email, family_name Identity claims a user token must carry. See Required claims.
Issuer No — defaults to Authority Only for a registration that issues tokens under a different issuer than its endpoint family.
ScopeClaimTypes No — accepts all known shapes Narrowing this can deny every delegated request. See Scope claim types.
UseApiPolicyAsFallback No — defaults to true The API policy protects every unannotated endpoint. Your public routes need AllowAnonymous. See Authorization policies.
DashboardAccessEmails / DashboardAccessPredicate Only if you use the Dashboard policy Without either, no one passes it.
DisableTlsValidation No — defaults to false Local development against a self-signed provider certificate only. Does not relax token validation. See TLS validation.

Supply Authority and Audience per environment, and do not rely on a baked-in default for them. A default in appsettings.json satisfies the startup guard in every environment, including the ones that should have overridden it — so an environment that forgets its override starts cleanly and then rejects every token against the wrong authority or audience. The guards catch a missing value, not a wrong one.

Identity provider — required configuration

The package reads flat, mapped claims and validates issuer, audience, and signature. It does not transform claims. Your provider must therefore emit:

Requirement Microsoft Entra ID Keycloak
Delegated identity claims — sub, email, given_name, family_name email, given_name, family_name must be added as optional access-token claims. A tenant that skips this returns 403 on every delegated request. Mapped by default in a standard realm.
A scope claim containing RequiredScope scp, handled automatically scope, handled automatically
App roles as a flat roles claim, for the service-to-service path Emitted flat already Needs a protocol mapper; realm roles are nested under realm_access by default
The API in the token's aud claim Present by default Needs an audience mapper; Keycloak omits it otherwise

For Entra ID, accessTokenAcceptedVersion should be 2. It is not reliably surfaced in the Azure portal — set it in the app manifest JSON or via bicep. See Authority for what happens on v1.0.

Application code — required wiring

  • app.UseAuthentication() before app.UseAuthorization().
  • Mark your public routes AllowAnonymous. The API policy protects every endpoint by default, so health probes, the SPA entry document, and any configuration the browser fetches before authenticating must opt out — the last two prevent the application from loading at all. See Authorization policies.
  • Apply the Dashboard and Scalar policies to their routes yourself. They are registered but not applied, because only your application knows where those routes are.
  • User principal resolution is not included; see User principal resolution.

Installation

Centeva.Oidc pulls in Centeva.Oidc.Abstractions transitively.

dotnet add package Centeva.Oidc

Stable releases are published to nuget.org. Pre-releases go to GitHub Packages, which requires an authenticated source — add a nuget.config next to your solution:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="centeva" value="https://nuget.pkg.github.com/Centeva/index.json" />
  </packageSources>
  <packageSourceMapping>
    <packageSource key="nuget.org">
      <package pattern="*" />
    </packageSource>
    <packageSource key="centeva">
      <package pattern="Centeva.*" />
    </packageSource>
  </packageSourceMapping>
</configuration>

Supply credentials via dotnet nuget add source or the GITHUB_ACTOR / GITHUB_TOKEN environment variables — do not commit a token.

Required using directives

AddOidcAuth() and WithOptions() live in Centeva.Oidc.Extensions; OidcConstants lives in Centeva.Oidc.Abstractions. The samples below assume:

using Centeva.Oidc.Abstractions;   // OidcConstants
using Centeva.Oidc.Extensions;     // AddOidcAuth, WithOptions

Usage

builder.Services.AddOidcAuth()
    .WithOptions(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = builder.Configuration["Auth:Audience"];
        options.ClientId = builder.Configuration["Auth:ClientId"];
        options.RequiredScope = "api.access";    // default: "all"
        options.AppOnlyRoles = ["ServiceAccount"];  // empty by default; omit if unused
        options.DashboardAccessPredicate = email => email.EndsWith("@example.com");
        // options.DisableTlsValidation = true;  // development only — see below
    });

A working version of the above, against a real Keycloak, is in samples/ — including the audience mapper a browser client's token needs before this API will accept it, which is the first thing most adopters hit.

Authority

The authority is the provider's OIDC issuer URL. It is the only place the provider is named:

Provider Authority
Microsoft Entra ID https://login.microsoftonline.com/{tenant}/v2.0
Keycloak https://{host}/realms/{realm}

Two notes on Entra ID. The /v2.0 suffix selects the v2.0 endpoint family, which is what pairs with an app registration whose manifest sets accessTokenAcceptedVersion: 2 — omitting it silently selects v1.0, which uses a different issuer form (https://sts.windows.net/{tenant}/) and identifies calling applications with appid rather than azp. accessTokenAcceptedVersion is not reliably surfaced in the Azure portal; set it in the app manifest JSON or via bicep.

If a browser front end signs in against the same provider — MSAL.js, for example — it is configured with this same authority. That is the front end's own configuration; this package neither reads nor requires it. Deriving the value two different ways on the two sides is how they drift apart, so configure both from one source.

For Angular applications, @centeva/msal-angular is the front-end counterpart to this package and takes the same authority value.

Audience and ClientId

Option Refers to Used by
Audience the API registration — the identifier a token is issued for JwtBearer token validation
ClientId the client this application signs in as, server-side Cookie + OpenIdConnect sign-in for Hangfire / API docs

ClientId is easy to misread. It is not your SPA's client ID. A browser front end signing in with MSAL.js has its own registration, configured in the front end, which this package never sees. ClientId here is only for the server-rendered login this package performs on its own behalf — the Hangfire dashboard and the API docs routes. If your application exposes neither, ClientId is unused.

Under a two-registration setup (an API registration exposing scopes and app roles, plus a separate client registration for the front end), Audience and ClientId may well be the same value — both naming the API registration — while the front end uses the other one. Whichever registration you point ClientId at must permit interactive sign-in and have a redirect URI for /signin-oidc.

This package supplies no client secret, so that registration must be usable without one: a public client, or a confidential client configured to permit authorization code with PKCE and no client authentication. A confidential registration that requires authentication at the token endpoint will fail the code exchange — see ClientSecret in ADR 0003.

Authority and Audience are both required. Registration throws an InvalidOperationException if either is null, empty, or whitespace, so the failure lands at startup rather than on the first request. Audience validation is unconditional; there is no configuration in which this package accepts a token whose audience it has not checked.

Note that the audience value is the API registration's identifier as it appears in the token's aud claim, which is not always its App ID URI. Entra v2.0 tokens typically carry the API's client-ID GUID rather than api://…. Read a real token and match what is there. If validation fails on a mismatch, fix the value — clearing Audience is not an escape hatch, it is a startup error.

Token expiry and clock skew

Expired tokens are tolerated for one minute, not the framework's default of five.

The tolerance exists for clock drift between this server and the provider, not for the client's convenience. A browser client using MSAL.js renews its access token five minutes before it expires, so it never presents an expired one; and on a fifteen-minute access token the default five-minute grace is a third of its life, during which a token that has already expired — including one issued to a session since revoked — is still accepted.

If your servers cannot keep their clocks within a minute of the provider's, fix the clock rather than the validation.

Default authentication scheme

The package registers three schemes and makes JwtBearer the default. This matters for a bare [Authorize], or a .RequireAuthorization() naming no policy: those fall to the framework's DefaultPolicy, which names no scheme either. Without a default there is nothing to authenticate or challenge with, and the request fails with an exception rather than a 401.

Note that DefaultPolicy only requires an authenticated user — it does not apply this package's claim requirements. Name a policy, or rely on the fallback, to get those.

TLS validation in local development

DisableTlsValidation exists for local development against a provider with a self-signed certificate. It sets RequireHttpsMetadata = false and installs a permissive back-channel HTTP handler for metadata and JWKS requests. It does not relax token validation: issuer, audience, and signing-key validation stay on in every environment.

Scope claim types

A delegated (user) token is recognized by finding RequiredScope in its scope claim. Providers disagree on what that claim is called, and Microsoft.IdentityModel renames one of them in transit, so three claim types are accepted by default:

Claim type Emitted by
scope Keycloak, and standard OIDC
scp Entra ID, when inbound claim mapping is disabled
http://schemas.microsoft.com/identity/claims/scope Entra ID, when inbound claim mapping is enabled — the default

The third looks redundant and is not. Inbound claim mapping rewrites scp to that URI before any authorization handler runs, so without it no delegated Entra ID request can be authorized. OidcConstants.Defaults.MappedScopeClaimType names it.

ScopeClaimTypes narrows the set if you know exactly what your provider emits:

options.ScopeClaimTypes = ["scope"];   // Keycloak only

Narrowing is rarely worth it. Removing the wrong entry denies every delegated request, and the failure surfaces as a missing application role rather than a missing scope.

Claim names are accepted in both forms

Microsoft.IdentityModel rewrites short JWT claim names into ClaimTypes URIs before any authorization handler runs, and whether it does so depends on host configuration this package does not control. Every lookup therefore accepts both forms, and neither is privileged:

Claim Accepted as
Subject ClaimTypes.NameIdentifier or sub
Given name ClaimTypes.GivenName or given_name
Email ClaimTypes.Email or email
Surname ClaimTypes.Surname or family_name
App role ClaimTypes.Role or roles
Calling application azp or appid

azp and appid are the same claim under two token versions — Entra ID emits azp on v2.0 and appid on v1.0. Accepting both means a tenant's accessTokenAcceptedVersion is not something a deployment has to get right.

App roles must be flat. Both names above are accepted, but a nested claim — Keycloak's realm_access.roles, for instance — is not read. See the provider requirements above.

Required claims and diagnostics

A delegated (user) token must carry four identity claims: sub, given_name, email, family_name. Narrow the set if your application doesn't need a full profile, or extend it to require something your provider emits:

options.RequiredDelegatedClaims =
[
    new RequiredClaim("sub", OidcConstants.Defaults.DelegatedClaimTypes.Subject),
    new RequiredClaim("email", OidcConstants.Defaults.DelegatedClaimTypes.Email),
    new RequiredClaim("employee_id", "employee_id"),   // your own
];

Each entry names the claim for diagnostics and lists every claim type that satisfies it.

When a claim is missing, the response and the log deliberately differ. The 403 says nothing specific — an unauthenticated caller learns only that it was denied. The log names exactly which claim was absent, at Warning:

API authorization failed: delegated token is missing required identity claim(s) email,
family_name. On Microsoft Entra ID these are optional access-token claims a tenant
administrator must add; on Keycloak they come from realm protocol mappers.

This matters most when the identity provider is administered by someone else. A tenant that skips the optional-claims step produces a blanket 403 on every user request, and without this line there is nothing to distinguish that from a bad token.

Claim names are logged; claim values never are. email, given_name, and family_name are personal data, so the log identifies the absent claim without reproducing anything the token carried.

The app-only path logs on the same terms — a missing app role, or a role present with no azp/appid. If AppOnlyRoles is empty the path is disabled, and the log says that rather than reporting a missing role, so nobody goes hunting for a role assignment that was never meant to exist.

Middleware order

Ensure the following order in Program.cs:

app.UseAuthentication();
app.UseAuthorization();

User principal resolution

User principal resolution is owned by the Centeva.PrincipalProvider package (v2+). Add it alongside this package and wire it up in Program.cs:

dotnet add package Centeva.PrincipalProvider

Minimal wiring

// Program.cs
builder.Services.AddOidcAuth()
    .WithOptions(options => { /* ... */ });

builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<TaskRunnerPrincipalProvider>(sp =>
    new TaskRunnerPrincipalProvider(
        authorizationKey: builder.Configuration["TaskRunner:AuthorizationKey"]!,
        authorizedUserName: "TaskRunnerUser"
    )
);
builder.Services.AddScoped<IPrincipalProvider, WebPrincipalProvider>();

Accessing claims in application services

IPrincipalProvider exposes the OIDC claims your services need most:

public class UserService(IPrincipalProvider principal, AppDbContext db)
{
    public Task<AppUser?> GetCurrentUserAsync() =>
        db.Users.FirstOrDefaultAsync(u => u.Email == principal.Email);
}
Property JWT claim Delegated token App-only token
Email email / ClaimTypes.Email
ApplicationId azp / appid
UserPrincipal full ClaimsPrincipal
AuthorizedUserName — (a display label)

Centeva.PrincipalProvider 2.0 reads each of these claims under both of the names this package accepts — mapped or raw for Email, azp or appid for ApplicationId. So a request this package authorizes always resolves to a non-null value there. Version 1.x does not: its Email and ApplicationId each read one name only, which is silent when it bites — the caller is authorized and the property is null.

This package requires sub on every delegated token, but Centeva.PrincipalProvider deliberately does not surface it as a property — a subject identifier is unique only within one issuer, and is not portable across clients, realms, or environments. Read it from UserPrincipal if an application genuinely needs it.

Background jobs (Hangfire)

Supply the identity the job's application code needs to resolve:

builder.Services.AddSingleton<TaskRunnerPrincipalProvider>(sp =>
    new TaskRunnerPrincipalProvider(
        authorizationKey: builder.Configuration["TaskRunner:AuthorizationKey"]!,
        authorizedUserName: "TaskRunnerUser",
        email: "jobs@example.com"
    )
);

Inject IPrincipalProvider and TaskRunnerPrincipalProvider into your Hangfire job and set AuthorizationKey before the job executes so WebPrincipalProvider switches to the task-runner identity automatically.

These values are exposed through IPrincipalProvider and as claims on its UserPrincipal. They do not satisfy the Dashboard policy: that is an ASP.NET Core authorization policy evaluated against HttpContext.User, and a background job issues no HTTP request, so no policy runs against it. Dashboard access is decided by the email on the browser user's cookie principal, via DashboardAccessPredicate or DashboardAccessEmails.

Authorization policies

Three named policies are registered automatically:

Policy Scheme Use
API JwtBearer API endpoints
Dashboard Cookie Hangfire dashboard (email-gated)
Scalar Cookie Scalar API reference routes

Access the policy names via OidcConstants.Policies.

API is applied for you, as the authorization fallback. Every endpoint that carries no authorization metadata of its own requires a valid token. Dashboard and Scalar are registered but must be applied to their routes explicitly — only your application knows where those routes are.

What this package decides, and what it doesn't

There are two questions, and this package answers only the first:

Question Answered by
May you call this API at all? This package — the API policy, RequiredScope, AppOnlyRoles
May you do this operation? Your application — per-endpoint [Authorize(Policy = "...")]

The distinction matters because the two use different discriminators for different reasons. The authorization handler routes on scope presence, because it has to decide between the delegated and app-only paths before it knows anything else about the token. An application choosing a user-lookup strategy might route on something else entirely, such as whether an email claim is present. Those are two different jobs, and collapsing them into one option here would not reduce the complexity — it would move it somewhere less visible.

Per-endpoint policies are more work than a single global switch. They are also the only thing that can express operation-level rules, so that is where the work belongs.

The same boundary holds on the front end: @centeva/msal-angular answers are you authenticated and delegates may you do this operation to the application. App-only and client-credentials callers have no front-end analogue — they are a server-side concern only.

Public routes must opt out

Because the fallback is on, anything that must stay reachable without a token needs AllowAnonymous. Audit these when adopting:

app.MapHealthChecks("/health").AllowAnonymous();
app.MapFallbackToFile("index.html").AllowAnonymous();   // SPA cannot load without this
app.MapGet("/config", ...).AllowAnonymous();            // fetched before authenticating

The middle two are the ones that bite. A SPA's entry document, and any configuration it fetches during bootstrap, are requested by a browser that has not authenticated yet. Miss them and the application does not load at all — there is no tidy 401 in a log to point at the cause.

This is deliberate: an endpoint you forget to annotate is protected rather than open. The trade is that the routes which should be open are the ones you have to remember.

Opting out of the fallback

options.UseApiPolicyAsFallback = false;   // default: true

Then apply the policy explicitly. The usual pattern is a base controller, inherited by every controller deriving from it:

[Authorize(Policy = OidcConstants.Policies.Api)]
public abstract class ApiBaseController : ControllerBase;

An explicit policy name on an [Authorize] attribute resolves at request time, so this works regardless of registration order. Prefer it only if enumerating your public routes is genuinely impractical — with the fallback off, an endpoint nobody annotated is silently anonymous.

Endpoints that need the Cookie-based policies instead of API must say so explicitly; see the two sections below.

Hangfire dashboard

The Dashboard policy uses Cookie authentication and evaluates the email claim against DashboardAccessPredicate (checked first) or DashboardAccessEmails (fallback). In practice, the allowed list comes from appsettings.json:

builder.Services.AddOidcAuth()
    .WithOptions(options =>
    {
        options.Authority = "https://auth.example.com/realms/my-realm";
        options.Audience = "my-api";
        options.ClientId = "my-client";

        // Load allowed emails from config (e.g. "MyApp:DashboardAccess": ["user@example.com"])
        options.DashboardAccessEmails = builder.Configuration
            .GetSection("MyApp:DashboardAccess")
            .Get<IEnumerable<string>>();

        // Or use a predicate (e.g. restrict by email domain)
        options.DashboardAccessPredicate = email => email.EndsWith("@example.com");
    });

Map the dashboard route and apply the policy in Program.cs:

app.MapHangfireDashboard().RequireAuthorization(OidcConstants.Policies.Dashboard);

app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    Authorization = []   // ASP.NET Core policy (above) handles access; bypass Hangfire's own filter
});

Scalar API reference

There are two separate layers of authentication for Scalar:

  1. Route protection (Cookie) — The Scalar policy requires an authenticated Cookie session, so an unauthenticated visitor is redirected to the OIDC login page and returned after signing in. This policy is registered for you, but you must apply it to the routes. Without an explicit RequireAuthorization the API fallback policy takes over instead, and a browser visiting the docs gets a bare 401 rather than a login redirect — the routes are protected, but unusably so.

  2. API call authentication (OAuth2 Bearer) — Once inside Scalar, each API request must carry a Bearer token. Configure Scalar's built-in OAuth2 flow so it can acquire one.

app.MapOpenApi()
   .RequireAuthorization(OidcConstants.Policies.Scalar);

app.MapScalarApiReference(options =>
{
    options
        .WithTitle("My API")
        .WithPreferredScheme("Bearer")
        .WithOAuth2Authentication(oauth =>
        {
            oauth.ClientId = "your-client-id";
            oauth.Scopes = ["openid", "profile", "all"];
        });
})
   .RequireAuthorization(OidcConstants.Policies.Scalar);

The two layers are independent: the Cookie gets you into Scalar UI, the OAuth2 token is what Scalar sends on your behalf to the API endpoints.

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 is compatible.  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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 139 8/26/2026