Authio.AspNetCore
0.6.0
dotnet add package Authio.AspNetCore --version 0.6.0
NuGet\Install-Package Authio.AspNetCore -Version 0.6.0
<PackageReference Include="Authio.AspNetCore" Version="0.6.0" />
<PackageVersion Include="Authio.AspNetCore" Version="0.6.0" />
<PackageReference Include="Authio.AspNetCore" />
paket add Authio.AspNetCore --version 0.6.0
#r "nuget: Authio.AspNetCore, 0.6.0"
#:package Authio.AspNetCore@0.6.0
#addin nuget:?package=Authio.AspNetCore&version=0.6.0
#tool nuget:?package=Authio.AspNetCore&version=0.6.0
Authio.AspNetCore
Protect an ASP.NET Core API (.NET 8 and .NET 10) with Authio:
- Access-token validation with the standard JWT bearer handler, configured from the realm's discovery document: issuer, explicit audiences, asymmetric algorithms only, small clock skew, Authio's claim names kept as they are.
- Introspection, optional: confirm on every request (cached per
jti) that the token was not revoked and the session did not end, or validate opaque / HMAC / encrypted tokens purely by introspection. - Authio roles: the
*_resource_accessclaims are flattened intoroleandpermissionclaims, and the full model is one call away. - Authorization: scope, role, permission (Authio's exact rule), organization, step-up (
acr/amr) and client requirements, as policy-builder helpers, minimal API helpers and controller attributes, with RFC 6750 / RFC 9470WWW-Authenticateerrors and optional problem details. - A typed caller:
User.GetAuthioUser()(subject, client, session, scopes, roles, permissions, organization, account,acr/amr, delegation chain, profile). - Webhooks: a signed-delivery receiver with secret rotation, timestamp tolerance, de-duplication and typed handlers.
- Downstream calls: forward the caller's token, or exchange it on behalf of the user (token exchange), cached.
It builds on Authio.Client; machine-to-machine calls use its
AddAuthioClientCredentials.
dotnet add package Authio.AspNetCore
Quick start
{
"Authio": {
"Authority": "https://auth.example.com",
"Realm": "acme",
"Audiences": [ "https://orders.example.com" ]
}
}
using Authio.AspNetCore.Defaults;
using Authio.AspNetCore.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddAuthentication(AuthioJwtBearerDefaults.AuthenticationScheme)
.AddAuthioJwtBearer(builder.Configuration.GetSection("Authio"));
builder.Services.AddAuthioAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/orders", (HttpContext context) => context.GetAuthioUser())
.RequireAuthioScope("orders:read");
app.Run();
Token validation
AddAuthioJwtBearer registers the scheme AuthioBearer (AuthioJwtBearerDefaults.AuthenticationScheme, or any name
with the (scheme, configuration, configure) overload). It reads the realm from AuthioOptions (Authority +
Realm, or Issuer) and its own settings from AuthioJwtBearerOptions; the configuration overload binds both from
the same section. With the Action<AuthioJwtBearerOptions> overload, configure AuthioOptions through
services.AddAuthioClient(...).
What the handler checks:
| Check | Value |
|---|---|
| Metadata | {issuer}/.well-known/openid-configuration through the Authio named HttpClient; keys from its jwks_uri |
| Issuer | exactly {Authority}/realms/{Realm} (a different issuer in the discovery document is not trusted) |
| Audience | any of Audiences (required) |
| Algorithms | RS256/384/512, PS256/384/512, ES256/384/512 (ValidAlgorithms to narrow) |
| Lifetime | exp/nbf required, ClockSkew default 30 s |
| Token type | the typ claim must be Bearer: ID and refresh tokens are refused (RejectNonAccessTokens) |
| Claims | MapInboundClaims = false: sub, scope, azp… keep their names |
| Name | preferred_username, or sub when the token has none (client-credentials tokens) |
| Roles | role claims flattened from *_resource_access (MapAuthorizationClaims) so IsInRole works |
Several realms
Each scheme validates against its own realm. Its AuthioOptions are named after the scheme: they start as a copy of
the shared ones (AddAuthioClient, or the default scheme's section), then take the scheme's own section, then
services.Configure<AuthioOptions>(scheme, ...) registered after AddAuthioJwtBearer. A scheme that names no realm
validates against the client's.
builder.Services
.AddAuthentication(AuthioJwtBearerDefaults.AuthenticationScheme)
.AddAuthioJwtBearer(builder.Configuration.GetSection("Authio"))
.AddAuthioJwtBearer("Partners", builder.Configuration.GetSection("AuthioPartners"), null);
app.MapGet("/catalog", List).RequireAuthorization(policy => policy
.AddAuthenticationSchemes(AuthioJwtBearerDefaults.AuthenticationScheme, "Partners")
.RequireAuthenticatedUser());
Only the default scheme binds its section into the shared AuthioOptions. The Authio client — and with it
introspection and on-behalf-of — stays on that one realm, so Introspection other than None on a scheme of another
realm stops the application at startup.
Audiences
Authio sets aud to the RFC 8707 resource identifier when the client asked for a registered resource server
(resource=https://orders.example.com on the authorize or token request), and otherwise to the audience configured
on the client that requested it, its own client id unless changed: that token is addressed to the client, not to
an API. So register your API as a resource server in Authio and accept its identifier:
"Audiences": [ "https://orders.example.com" ]
The application that calls the API has to ask Authio for a token for that resource: resource=https://orders.example.com
on its authorize or token request, which with Authio.AspNetCore.Bff is Authio:Bff:Resource for the session's
token, or Resource on a remote API or typed client (exchanged for it). A token without it is addressed to the
client that requested it, and the API refuses it with 401:
WWW-Authenticate: Bearer error="invalid_token", error_description="The token is not addressed to this API: its audience is not 'https://orders.example.com'. Request the token for that resource (RFC 8707)."
The token's own audience is named too when the validation reports it, as IntrospectionOnly does.
Options
Key (Authio:) |
Default | Meaning |
|---|---|---|
Audiences |
— (required) | Accepted audiences |
Introspection |
None |
None, ActiveCheck or IntrospectionOnly |
IntrospectionCacheDuration |
00:00:30 |
How long an active answer is reused (never past exp); 0 disables |
ClockSkew |
00:00:30 |
Lifetime tolerance |
ValidAlgorithms |
asymmetric list | Accepted JWS algorithms |
RequireHttpsMetadata |
Authio:RequireHttps |
HTTPS for discovery and keys |
MapAuthorizationClaims |
true |
Add role / permission claims |
RejectNonAccessTokens |
true |
Refuse tokens whose typ is not Bearer |
SaveToken |
true |
Keep the token in the authentication properties |
Anything else of JwtBearerOptions can be changed with services.Configure<JwtBearerOptions>(scheme, ...); an
OnTokenValidated you set there runs after the Authio step.
Introspection
A JWT stays valid until it expires even after a logout, a revocation, a suspension or a withdrawn delegation.
Authio's back-channel logout tells the client a person signed in to, not the APIs that accept its tokens, so two
modes ask Authio's introspection endpoint instead. Both need
services.AddAuthioClient(...) with a confidential client allowed to introspect the API's tokens (in Authio, the
client the resource server names as its introspection client): Authio:ClientId and Authio:ClientSecret, or, with
Authio:ClientAuthentication = PrivateKeyJwt, its private key (Authio:ClientPrivateKeyPath or
Authio:ClientPrivateKey, plus Authio:ClientKeyId) so no secret is sent; see
Authio.Client. On-behalf-of
and the client-credentials handler authenticate the same way.
ActiveCheck: after local validation, the token must beactive. Active answers are cached perjtiforIntrospectionCacheDuration, which bounds how long a revoked token is still accepted. If Authio cannot be reached the request is refused (fail closed).IntrospectionOnly: for clients that issue HMAC-signed or encrypted (JWE) tokens, which are not in the JWKS. The token is not read locally; the answer must beactive, from the configured issuer, for an accepted audience and not expired. The principal is built from the answer (sub,azpfromclient_id,scope,preferred_usernamefromusername,aud,jti,exp…) plus the claims Authio adds to an active answer: the*_resource_accessrole maps,acr,amr,org_id/org_alias,act,sidandtyp. Every requirement works as it does with local validation, andtoken_typeis checked liketyp, so refresh and ID tokens are refused. Needs an Authio server that writes those claims (October 2026 onwards); an older one answers without them, and role, permission, sign-in and organization requirements then refuse.
"Authio": {
"Authority": "https://auth.example.com",
"Realm": "acme",
"ClientId": "orders-api",
"ClientSecret": "<secret>",
"Audiences": [ "https://orders.example.com" ],
"Introspection": "ActiveCheck"
}
builder.Services.AddAuthioClient(builder.Configuration.GetSection("Authio"));
builder.Services
.AddAuthentication(AuthioJwtBearerDefaults.AuthenticationScheme)
.AddAuthioJwtBearer(builder.Configuration.GetSection("Authio"));
Who can introspect a token
Authio answers an introspection request according to who asks, as RFC 7662 lets an authorization server do
(§2.2: a protected resource that "is not allowed to introspect this particular token" gets active: false; §4: the
server authorizes which protected resources may introspect):
| The token was requested… | Its aud |
Introspected by the API's client (the resource server's introspection client) | Introspected by the client that requested it |
|---|---|---|---|
with resource=https://orders.example.com |
the resource | active: true |
active: false, by design |
| without a resource | the requesting client | active: false |
active: true |
- The API is the token's audience, so the API is the one that introspects it: that is what
ActiveCheckandIntrospectionOnlydo, with the client the resource server names in Authio. - To the client that requested it, an access token for a resource is opaque (RFC 6749 §1.4): the client sends it and
does not read it. Its own introspection answers
active: false, which says nothing about whether the API accepts the token. A worker that wants to see its token introspected asks for one without a resource. - An
active: falseanswer is refused with401anderror_description="Authio reports the token as not active to this API's client. …": it was revoked, it expired, its session ended, or the API'sAuthio:ClientIdis not the introspection client of the token's resource server.
The caller
app.MapGet("/me", (HttpContext context) =>
{
var user = context.GetAuthioUser(); // or User.GetAuthioUser() in a controller
var token = context.GetAuthioAccessToken(); // the raw bearer token
var access = context.GetAuthioResourceAccess(); // roles model, parsed once per request
return user;
}).RequireAuthorization();
AuthioUser holds Subject, ClientId (azp), SessionId (sid), TokenId (jti), Audiences, Scopes,
Roles, Permissions (resource → methods), ResourceAccess (with the source of each role), OrganizationId /
OrganizationAlias, AccountId, Acr / AuthenticationLevel, AuthenticationMethods (amr),
IdentityProvider (idp, the external provider of a federated sign-in), AuthenticatedAt, Actor (the act chain of a delegated token, nested actors included), DelegationId,
IsDelegated, Email, EmailVerified, Name and PreferredUsername.
Roles and permissions are only in tokens issued with the roles scope. Other principal helpers:
GetAuthioScopes(), HasAuthioScope(scope), HasAuthioRole(role), HasAuthioPermission(resource, method),
GetAuthioAuthenticationLevel(), GetAuthioAuthenticationMethods(), GetAuthioActor().
For Authio principals that come from another scheme (for example a cookie session signed in with OpenID Connect),
services.AddAuthioClaimsTransformation() adds the same role / permission claims; it is idempotent.
Authorization
Every requirement is available three ways.
// Policies
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("orders.write", policy => policy
.RequireAuthioScope("orders:write")
.RequireAuthioPermission("orders", "write"));
});
// Minimal APIs (endpoints and route groups)
app.MapGet("/orders", List).RequireAuthioScope("orders:read", "orders:admin");
app.MapGroup("/admin").RequireAuthioRole("admin").RequireAuthioAuthenticationMethod("mfa");
// Controllers (needs services.AddAuthioAuthorization())
[AuthioScope("orders:read")]
[AuthioPermission("orders", "write")]
public IActionResult Create() => ...;
| Requirement | Helper / attribute | Passes when |
|---|---|---|
| Scope | RequireAuthioScope([match,] scopes) / [AuthioScope(..., RequireAll = true)] |
any (default) or all scopes are in scope, compared exactly |
| Role | RequireAuthioRole([match,] [sources,] roles) / [AuthioRole(..., RequireAll = true, Sources = ...)] |
any or all roles are keys of a *_resource_access claim (or role claims), case-insensitive; with sources, only roles from those claims count (Client for client_resource_access, Realm, User, Plan, Consumer, Secret, System) |
| Permission | RequireAuthioPermission(resource, method[, sources]) / [AuthioPermission(..., Sources = ...)] |
Authio's ResourceAccessRules.Grants: any role lists the method under the resource, case-insensitive; with sources, only roles from those claims count (e.g. System to reserve an action for platform roles) |
| Organization | RequireAuthioOrganization(organization?) / [AuthioOrganization] |
org_id is present; with an argument, org_alias or org_id equals it (case-insensitive) |
| Step-up level | RequireAuthioAuthenticationLevel(min) / [AuthioAuthenticationLevel] |
numeric acr ≥ min |
| Step-up method | RequireAuthioAuthenticationMethod([match,] methods) / [AuthioAuthenticationMethod(..., RequireAll = true)] |
any or all methods are in amr (pwd, otp, swk, mca, fed, mfa…); fed is a sign-in through an external identity provider, counted as one factor, and comes with an idp claim holding the provider's alias (google, github) |
| Client | RequireAuthioClient(clientIds) / [AuthioClient] |
azp is one of the clients |
Several attributes on one action all apply. The attributes generate policy names (Authio:scope.any:...) that the
AuthioAuthorizationPolicyProvider registered by AddAuthioAuthorization() turns into policies; every other policy
name falls back to the policies you registered.
Refusals
With AddAuthioAuthorization():
- a missing scope answers
403withWWW-Authenticate: Bearer error="insufficient_scope", error_description="...", scope="orders:read orders:write"(RFC 6750 §3.1); - a failed step-up (
acr/amr) answers403withWWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="..."(RFC 9470), so the client can send the user through a stronger login (Authio honoursacr_valueson a best-effort basis: the client starts a new login withacr_values=2, or3for a passkey, and still checks theacrof the new token, because Authio issues the level the sign-in actually reached); - other refusals are a plain
403; an invalid or missing token is the bearer handler's401witherror="invalid_token"and anerror_descriptionthat says why: the bearer handler's own (expired, signature, issuer) or Authio's, for a token addressed to another audience, one that is not an access token, or one introspection reports as not active or cannot confirm. A refusal your ownOnTokenValidatedmakes keeps its message out of the header;JwtBearerOptions.IncludeErrorDetails = falseleaves every description out.
builder.Services.AddAuthioAuthorization(options =>
{
options.WriteBearerErrors = true; // default
options.WriteProblemDetails = true; // application/problem+json body on 401/403 (default false)
});
Webhooks
"Authio": {
"Webhooks": {
"Secret": "whsec_...",
"Secrets": [ "whsec_previous..." ],
"Tolerance": "00:05:00",
"Deduplication": "Memory",
"DeduplicationWindow": "1.00:00:00",
"MaxBodySize": 1048576
}
}
builder.Services
.AddAuthioWebhooks(builder.Configuration.GetSection("Authio:Webhooks"))
.AddHandler<AuditTrailHandler>() // IAuthioWebhookHandler: every event
.AddHandler<AuthioWebhookUserData, UserSyncHandler>() // IAuthioWebhookHandler<AuthioWebhookUserData>
.On(AuthioWebhookEventTypes.KeysRotated, (evt, services, ct) => ...)
.On<AuthioWebhookTokenRevokedData>(AuthioWebhookEventTypes.TokenRevoked, (evt, data, services, ct) => ...)
.OnAny((evt, services, ct) => ...);
app.MapAuthioWebhooks(); // POST /webhooks/authio, anonymous (the signature authenticates)
The receiver reads the raw body, verifies X-Authio-Signature (HMAC-SHA256 over {t}.{body} with the full
whsec_ secret, constant-time) against every configured secret, so a rotation can list the old and new secrets,
checks the timestamp against Tolerance, parses the event and skips an X-Authio-Event-Id it has already handled.
| Answer | When |
|---|---|
200 |
handled, a duplicate, or ping |
400 |
signed, but not an Authio event |
401 |
missing, wrong or stale signature |
413 |
body over MaxBodySize |
500 |
a handler threw; the event id is forgotten so Authio's retry is handled again |
Typed handlers receive the data shape of their events: AuthioWebhookUserData (user_created, user_updated,
user_deleted), AuthioWebhookLoginData (login_succeeded, login_failed), AuthioWebhookUserLockedOutData,
AuthioWebhookTokenRevokedData, AuthioWebhookKeysRotatedData, AuthioWebhookTokenReuseDetectedData,
AuthioWebhookAdminResourceData (admin_resource_*), AuthioWebhookUserSuspensionData (user_suspended,
user_suspension_ended) and AuthioWebhookPingData. Handlers run before Authio gets its answer: keep them short and
enqueue long work. De-duplication is Memory (one instance), Distributed (register an IDistributedCache; the
check and the write are two calls) or None; replace IAuthioWebhookDeduplicationStore for anything else.
Downstream calls
// Same audience downstream: forward the caller's token as is.
builder.Services.AddHttpClient<ProfileApi>(c => c.BaseAddress = new("https://profile.example.com"))
.AddAuthioTokenForwarding();
// Another API, as the user: token exchange (RFC 8693). The delegated token keeps the user as sub,
// names this API in act, and has the downstream resource as aud.
builder.Services.AddAuthioClient(builder.Configuration.GetSection("Authio"));
builder.Services.AddHttpClient<BillingApi>(c => c.BaseAddress = new("https://billing.example.com"))
.AddAuthioOnBehalfOf(options =>
{
options.Resource = "https://billing.example.com";
options.Scopes = ["invoices:read"];
});
// Another API, as this API itself (no user): client credentials, from Authio.Client.
builder.Services.AddHttpClient<ReportsApi>(c => c.BaseAddress = new("https://reports.example.com"))
.AddAuthioClientCredentials(options => options.Resource = "https://reports.example.com");
On-behalf-of needs AddAuthioClient with this API's confidential client, allowed to exchange tokens in Authio. The
delegated token is cached per incoming token (jti), resource and scopes until Authio:TokenRefreshSkew before it
expires (concurrent requests share one exchange); on a downstream 401 it is dropped and the call retried once.
IAuthioOnBehalfOfTokenCache can also be used directly.
Testing
tests/Authio.Testing in the repository is an in-memory Authio (discovery, JWKS, token, introspection…) used by this
package's tests; route the Authio named client to it with services.AddFakeAuthioBackchannel(fake) and mint tokens
with fake.Tokens.CreateAccessToken(...).WithAudience(...).
License
MIT.
| 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
- Authio.Client (>= 0.6.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.10)
-
net8.0
- Authio.Client (>= 0.6.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 8.0.23)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.