Authio.AspNetCore
0.3.0
See the version list below for details.
dotnet add package Authio.AspNetCore --version 0.3.0
NuGet\Install-Package Authio.AspNetCore -Version 0.3.0
<PackageReference Include="Authio.AspNetCore" Version="0.3.0" />
<PackageVersion Include="Authio.AspNetCore" Version="0.3.0" />
<PackageReference Include="Authio.AspNetCore" />
paket add Authio.AspNetCore --version 0.3.0
#r "nuget: Authio.AspNetCore, 0.3.0"
#:package Authio.AspNetCore@0.3.0
#addin nuget:?package=Authio.AspNetCore&version=0.3.0
#tool nuget:?package=Authio.AspNetCore&version=0.3.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 client's configured
audience, "Authio" by default. Every token of the realm carries "Authio" unless configured otherwise, so
register your API as a resource server in Authio and accept its identifier:
"Audiences": [ "https://orders.example.com" ]
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
Authio has no back-channel logout: a JWT stays valid until it expires even after a logout, a revocation, a
suspension or a withdrawn delegation. Two modes ask Authio's introspection endpoint. Both need
services.AddAuthioClient(...) with a confidential client allowed to introspect the API's tokens (in Authio, the
client the resource server is registered to): Authio:ClientId and Authio:ClientSecret.
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"));
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),
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, mfa…) |
| 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 has noacr_values: the client starts a new login, e.g. withprompt=login, and the user completes MFA); - other refusals are a plain
403; an invalid or missing token is the bearer handler's401witherror="invalid_token".
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.3.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.10)
-
net8.0
- Authio.Client (>= 0.3.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.