Authio.AspNetCore 0.5.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package Authio.AspNetCore --version 0.5.1
                    
NuGet\Install-Package Authio.AspNetCore -Version 0.5.1
                    
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="Authio.AspNetCore" Version="0.5.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Authio.AspNetCore" Version="0.5.1" />
                    
Directory.Packages.props
<PackageReference Include="Authio.AspNetCore" />
                    
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 Authio.AspNetCore --version 0.5.1
                    
#r "nuget: Authio.AspNetCore, 0.5.1"
                    
#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 Authio.AspNetCore@0.5.1
                    
#: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=Authio.AspNetCore&version=0.5.1
                    
Install as a Cake Addin
#tool nuget:?package=Authio.AspNetCore&version=0.5.1
                    
Install as a Cake Tool

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_access claims are flattened into role and permission claims, 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 9470 WWW-Authenticate errors 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 be active. Active answers are cached per jti for IntrospectionCacheDuration, 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 be active, from the configured issuer, for an accepted audience and not expired. The principal is built from the answer (sub, azp from client_id, scope, preferred_username from username, aud, jti, exp…) plus the claims Authio adds to an active answer: the *_resource_access role maps, acr, amr, org_id/org_alias, act, sid and typ. Every requirement works as it does with local validation, and token_type is checked like typ, 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 ActiveCheck and IntrospectionOnly do, 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: false answer is refused with 401 and error_description="Authio reports the token as not active to this API's client. …": it was revoked, it expired, its session ended, or the API's Authio:ClientId is 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 403 with WWW-Authenticate: Bearer error="insufficient_scope", error_description="...", scope="orders:read orders:write" (RFC 6750 §3.1);
  • a failed step-up (acr / amr) answers 403 with WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="..." (RFC 9470), so the client can send the user through a stronger login (Authio honours acr_values on a best-effort basis: the client starts a new login with acr_values=2, or 3 for a passkey, and still checks the acr of 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's 401 with error="invalid_token" and an error_description that 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 own OnTokenValidated makes keeps its message out of the header; JwtBearerOptions.IncludeErrorDetails = false leaves 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.

Version Downloads Last Updated
0.9.0 118 10/14/2026
0.8.1 124 10/14/2026
0.8.0 114 10/14/2026
0.7.0 133 10/13/2026
0.6.0 116 10/9/2026
0.5.1 108 10/8/2026
0.5.0 96 10/8/2026
0.4.0 109 10/6/2026
0.3.0 114 10/5/2026
0.2.0 98 10/4/2026
0.1.0 102 10/4/2026