Authio.AspNetCore
0.7.0
See the version list below for details.
dotnet add package Authio.AspNetCore --version 0.7.0
NuGet\Install-Package Authio.AspNetCore -Version 0.7.0
<PackageReference Include="Authio.AspNetCore" Version="0.7.0" />
<PackageVersion Include="Authio.AspNetCore" Version="0.7.0" />
<PackageReference Include="Authio.AspNetCore" />
paket add Authio.AspNetCore --version 0.7.0
#r "nuget: Authio.AspNetCore, 0.7.0"
#:package Authio.AspNetCore@0.7.0
#addin nuget:?package=Authio.AspNetCore&version=0.7.0
#tool nuget:?package=Authio.AspNetCore&version=0.7.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;
access tokens Authio encrypts to the client's own key (nested JWE, RSA-OAEP or RSA-OAEP-256) decrypted with
Authio:ClientEncryptionKey. - Introspection, optional: confirm on every request (cached per
jti) that the token was not revoked and the session did not end, or validate opaque or HMAC tokens purely by introspection. - RFC 9728, opt-in: the API's protected resource metadata at
/.well-known/oauth-protected-resource{path}andresource_metadatain every401challenge. - 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, client, caller kind and step-up
(
acr,amr, recency) requirements, as policy-builder helpers, minimal API helpers and controller attributes, with RFC 6750 errors, the RFC 9470 step-up challenge and optional problem details. - A typed caller:
User.GetAuthioUser(): who called (a person, a client as itself, a client's consumer or an account credential, and with which credential), subject, client, session, scopes, roles, permissions, organization, account,acr/amr, impersonation and 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 unless Resource) |
Accepted audiences |
Resource |
— | The API's own resource identifier: accepted as an audience, and the resource of its RFC 9728 metadata |
PublishResourceMetadata |
false |
resource_metadata in 401 challenges (needs Resource); map the document with app.MapAuthioProtectedResourceMetadata() |
ResourceScopes, ResourceName, ResourceDocumentation |
— | scopes_supported, resource_name and resource_documentation of the metadata |
ClientEncryptionKey / ClientEncryptionKeyPath |
— | The client's private encryption key, for access tokens Authio encrypts to it |
AllowLegacyEncryptionAlgorithms |
false |
Decrypt JWEs wrapped with RSA1_5 |
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 tokens, which are not in the JWKS, or reference tokens. (An encrypted access token no longer needs it: withAuthio:ClientEncryptionKeyit is decrypted and validated locally, see Encrypted access tokens.) 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.
Encrypted access tokens
A token encrypted to the client's own key is a nested JWE: the bearer handler decrypts it with
Authio:ClientEncryptionKey (the private JWK Authio hands over, or a PEM; or ClientEncryptionKeyPath, read again
when the file changes) and validates the signed token inside like any other. Key management RSA-OAEP and
RSA-OAEP-256, content A128CBC-HS256, A192CBC-HS384 or A256CBC-HS512; RSA1_5 only with
Authio:AllowLegacyEncryptionAlgorithms = true. A token no key opens, or one with a refused algorithm, answers 401
with an error_description saying so.
Protected resource metadata (RFC 9728)
{ "Authio": { "Resource": "https://orders.example.com", "PublishResourceMetadata": true, "ResourceScopes": [ "orders:read" ] } }
app.MapAuthioProtectedResourceMetadata(); // GET /.well-known/oauth-protected-resource
The document names the resource, the realm's issuer as its authorization server and header as the bearer method;
every 401 answers WWW-Authenticate: Bearer resource_metadata="https://orders.example.com/.well-known/oauth-protected-resource", …,
so a client (an MCP client, for one) discovers where to sign in and which resource to ask for.
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 Kind (User, Client, Consumer or AccountCredential), CredentialForm (client_secret,
personal_access_token, api_key or consumer_api_key, for machines), ConsumerName, CredentialId,
CredentialName, OwnerUserId / OwnerUsername (who a personal access token or an account credential belongs to;
read from owner_username, or from the consumer_name older Authio servers wrote it to), CredentialRealm /
CredentialClient (what an account credential is limited to), Impersonated + Impersonator (Subject, Name),
Delegated + DelegationId + ActorChain (the clients acting for the user, outermost first), 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),
AuthenticatedAt, Actor (the raw act chain), Email, EmailVerified, Name and PreferredUsername. Every
Authio library derives these the same way (conformance/callers.json).
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[, maxAge]) / [AuthioAuthenticationLevel(min, MaxAgeSeconds = …)] |
numeric acr ≥ min (and, with maxAge, the sign-in at most that old) |
| Step-up recency | RequireAuthioRecentAuthentication(maxAge) / [AuthioRecentAuthentication(seconds)] |
auth_time is at most maxAge ago |
| 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 |
| Caller kind | RequireAuthioCallerKind(kinds) / [AuthioCallerKind(AuthioCallerKind.User)] |
GetAuthioUser().Kind is one of the kinds |
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 (level, method or recency) answers
401withWWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="...", acr_values="2 3", max_age=300(RFC 9470 §3):acr_valueslists the levels that would do (from the minimum level, or the level the methods bring:otp/mfa/mca2,swk/hwk/user3) andmax_agethe recency asked for. The client signs the user in again with them (IAuthioLoginClient.CreateStepUpAuthorizationAsync) and still checks the new token, because Authio issues the level the sign-in actually reached. WithWriteBearerErrors = falsethe refusal stays a plain403; - 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
The Authio.Testing package is an
in-memory Authio (discovery, JWKS, every grant, introspection, JWE to a client's key…); route the Authio named client
to it with services.AddFakeAuthioBackchannel(fake) and mint tokens with
fake.Tokens.CreateAccessToken(...).WithAudience(...) (WithAuthTime, WithActor, WithImpersonator, …).
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.7.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 10.0.10)
-
net8.0
- Authio.Client (>= 0.7.0)
- Microsoft.AspNetCore.Authentication.JwtBearer (>= 8.0.23)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Authio.AspNetCore:
| Package | Downloads |
|---|---|
|
Authio.Mcp.AspNetCore
Protects an ASP.NET Core MCP server (OAuth 2.1 resource server) with Authio, on top of Authio.AspNetCore: JWT, JWE and opaque token validation, RFC 9728 protected resource metadata and the MCP-ready WWW-Authenticate challenge, scope / role / permission / caller-kind requirements per tool with HTTP 403 and step-up 401 challenges, and the typed caller. |
GitHub repositories
This package is not used by any popular GitHub repositories.