Authio.AspNetCore 0.7.0

There is a newer version of this package available.
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
                    
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.7.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Authio.AspNetCore" Version="0.7.0" />
                    
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.7.0
                    
#r "nuget: Authio.AspNetCore, 0.7.0"
                    
#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.7.0
                    
#: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.7.0
                    
Install as a Cake Addin
#tool nuget:?package=Authio.AspNetCore&version=0.7.0
                    
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; 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} and resource_metadata in every 401 challenge.
  • 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, 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 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 tokens, which are not in the JWKS, or reference tokens. (An encrypted access token no longer needs it: with Authio:ClientEncryptionKey it is decrypted and validated locally, see Encrypted access tokens.) 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.

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 403 with WWW-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 401 with WWW-Authenticate: Bearer error="insufficient_user_authentication", error_description="...", acr_values="2 3", max_age=300 (RFC 9470 §3): acr_values lists the levels that would do (from the minimum level, or the level the methods bring: otp/mfa/mca 2, swk/hwk/user 3) and max_age the 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. With WriteBearerErrors = false the refusal stays a plain 403;
  • 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

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 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.8.1 0 10/14/2026
0.8.0 0 10/14/2026
0.7.0 0 10/13/2026
0.6.0 94 10/9/2026
0.5.1 95 10/8/2026
0.5.0 86 10/8/2026
0.4.0 91 10/6/2026
0.3.0 103 10/5/2026
0.2.0 91 10/4/2026
0.1.0 93 10/4/2026