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
<PackageReference Include="Centeva.Oidc" Version="1.0.0" />
<PackageVersion Include="Centeva.Oidc" Version="1.0.0" />
<PackageReference Include="Centeva.Oidc" />
paket add Centeva.Oidc --version 1.0.0
#r "nuget: Centeva.Oidc, 1.0.0"
#:package Centeva.Oidc@1.0.0
#addin nuget:?package=Centeva.Oidc&version=1.0.0
#tool nuget:?package=Centeva.Oidc&version=1.0.0
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()beforeapp.UseAuthorization().- Mark your public routes
AllowAnonymous. TheAPIpolicy 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
DashboardandScalarpolicies 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 |
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:
Route protection (Cookie) — The
Scalarpolicy 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 explicitRequireAuthorizationtheAPIfallback policy takes over instead, and a browser visiting the docs gets a bare401rather than a login redirect — the routes are protected, but unusably so.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 | 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 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. |
-
net10.0
- Centeva.Oidc.Abstractions (>= 1.0.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.3)
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 10.0.3)
-
net8.0
- Centeva.Oidc.Abstractions (>= 1.0.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 8.0.15)
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 8.0.15)
-
net9.0
- Centeva.Oidc.Abstractions (>= 1.0.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 9.0.6)
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 9.0.6)
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 |