Authio.Client 0.5.0

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

Authio.Client

The Authio client for .NET 8 and .NET 10, with no ASP.NET Core dependency. It covers:

  • Discovery: the realm's OpenID Connect document, cached, single-flight, issuer-checked.
  • Every token grant Authio supports: client credentials, authorization code with PKCE, refresh (with rotation), token exchange (delegation / on-behalf-of), connected-accounts exchange, device flow, and the discouraged password grant.
  • Client authentication: client_secret_basic, client_secret_post, none, and private_key_jwt (a signed assertion instead of a secret).
  • Introspection, revocation and userinfo.
  • Machine-to-machine calls: a client-credentials token cache and an HttpClient handler that adds the token, renews it before expiry and retries once on 401.
  • Authio roles: a parser for the *_resource_access claims with Authio's exact grant rule.
  • Webhooks: signature verification (X-Authio-Signature) and typed event bodies.
  • Admin API: the users of a realm and of a client, role assignment, roles, imports, sessions, groups and account credentials, authenticated with any account credential: an API key, a personal access token or a client secret.
dotnet add package Authio.Client

Configuration

{
  "Authio": {
    "Authority": "https://auth.example.com",
    "Realm": "acme",
    "ClientId": "orders-service",
    "ClientSecret": "<secret>",
    "ClientAuthentication": "Basic",
    "DiscoveryCacheDuration": "01:00:00",
    "HttpTimeout": "00:00:30",
    "TokenRefreshSkew": "00:01:00",
    "AdminCredential": "<account credential, only for the Admin API>",
    "AdminAuthentication": "ApiKey"
  }
}

The issuer is {Authority}/realms/{Realm}; set Issuer instead to give it directly (it wins). Every endpoint is then read from {issuer}/.well-known/openid-configuration. ClientAuthentication is Basic (default, client_secret_basic, each part percent-encoded as RFC 6749 §2.3.1 asks), Post (client_secret_post), None (public client) or PrivateKeyJwt (see below). Invalid settings fail at start-up with a message naming the key, e.g. Authio:ClientId is required.

builder.Services.AddAuthioClient(builder.Configuration.GetSection("Authio"));
// or
builder.Services.AddAuthioClient(options =>
{
    options.Authority = "https://auth.example.com";
    options.Realm = "acme";
    options.ClientId = "orders-service";
    options.ClientSecret = builder.Configuration["Authio:ClientSecret"];
});

Every protocol call goes through the named HttpClient AuthioDefaults.HttpClientName ("Authio"), and Admin API calls through AuthioDefaults.AdminHttpClientName, so you can add handlers (logging, resilience, proxies):

builder.Services.AddHttpClient(AuthioDefaults.HttpClientName).AddStandardResilienceHandler();

Nothing secret reaches a log. Both clients, and every client given AddAuthioClientCredentials, keep the values of Authorization, Proxy-Authorization, x-api-key, Cookie and Set-Cookie out of the HttpClient logs (RedactAuthioCredentialHeaders, on top of whatever the client already redacts). That matters on .NET 8, where Microsoft.Extensions.Http redacts no header by default; .NET 9 and later already redact them all. A later RedactLoggedHeaders on the same client replaces it. The models that hold a credential or a token (AuthioTokenResponse, AuthioAdminSecret, the secret and user requests, …) print [redacted] in its place from ToString().

Client authentication with a private key (private_key_jwt)

With ClientAuthentication = PrivateKeyJwt no secret ever leaves the service. Every request to the token, introspection, revocation and device endpoints carries a fresh client_assertion: a JWT signed with the client's private key, with iss = sub = the client id, aud = the realm's issuer, iat/nbf = now, exp two minutes out (ClientAssertionLifetime, at most one hour) and a new jti, so a captured assertion authenticates nobody. Grants, the client-credentials handler, the BFF and on-behalf-of all use it unchanged.

  1. Create a key pair (RSA 2048+ or EC P-256/P-384/P-521):

    openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out orders-service.pem
    
  2. Configure the private key, as PEM text or a PEM file (keep either in a secret store, never in a committed file):

    {
      "Authio": {
        "Authority": "https://auth.example.com",
        "Realm": "acme",
        "ClientId": "orders-service",
        "ClientAuthentication": "PrivateKeyJwt",
        "ClientPrivateKeyPath": "/run/secrets/orders-service.pem",
        "ClientKeyId": "orders-2026-10"
      }
    }
    

    ClientPrivateKey takes the PEM text instead (PKCS#8, PKCS#1 or SEC1; one-line text with escaped \n is accepted). The text or file may also hold a certificate or the EC PARAMETERS OpenSSL writes: the one private key is found among the blocks, and two keys are refused. An EC key is accepted by its curve, P-256, P-384 or P-521 (not secp256k1 or a Brainpool curve of the same size), and the messages name the setting the key came from. ClientSigningAlgorithm defaults to RS256 for RSA and to ES256/ES384/ES512 for the curve; PS256–PS512 and RS384/RS512 are accepted too. A key file is read again when it is rewritten, so a rotated mounted secret is picked up without a restart. ClientSecret is not needed and never sent.

  3. Register the public half on the client in the Authio console (inline JWK Set, or a jwks_uri you publish), under the same kid. The SDK writes the JWK Set for you, with no private parameter:

    var jwks = new AuthioOptions { ClientPrivateKeyPath = "orders-service.pem", ClientKeyId = "orders-2026-10" }
        .CreateClientSigningCredentials()
        .ToPublicJsonWebKeySet();
    

A key held in a certificate store, an HSM or a cloud KMS is set in code; it wins over the PEM settings:

builder.Services.AddAuthioClient(builder.Configuration.GetSection("Authio"), options =>
{
    options.ClientAuthentication = AuthioClientAuthentication.PrivateKeyJwt;
    options.ClientSigningCredentials = new SigningCredentials(
        new X509SecurityKey(certificate) { KeyId = "orders-2026-10" },
        SecurityAlgorithms.RsaSha256);
});

A missing, unreadable or unsuited key fails at start-up naming the key at fault. Authio refuses an assertion that is expired, too long-lived, replayed, signed with an unregistered key or sent beside a secret, with invalid_client.

Calling another API as this service (client credentials)

builder.Services
    .AddHttpClient<BillingApi>(client => client.BaseAddress = new Uri("https://billing.example.com"))
    .AddAuthioClientCredentials(options =>
    {
        options.Scopes = ["invoice:read"];
        options.Resource = "https://billing.example.com"; // becomes the token's aud (RFC 8707)
    });

The token is cached per scopes and resource, renewed TokenRefreshSkew before it expires, shared by concurrent callers, and on a 401 from the downstream API dropped and fetched again once. The client authenticates as ClientAuthentication says, PrivateKeyJwt included. To get a token yourself:

var token = await tokenCache.GetTokenAsync(["invoice:read"], "https://billing.example.com");

Token grants

public sealed class Example(IAuthioTokenClient tokens)
{
    public async Task Run(string code, string verifier, string userAccessToken)
    {
        var signIn = await tokens.ExchangeAuthorizationCodeAsync(code, "https://app.example.com/callback", verifier);
        var refreshed = await tokens.RefreshTokenAsync(signIn.RefreshToken!); // keep the new refresh token

        var onBehalf = await tokens.ExchangeTokenAsync(userAccessToken, ["orders:read"], "https://orders.example.com");

        var result = await tokens.ExchangeForConnectionAsync(userAccessToken, "github");
        var message = result switch
        {
            AuthioConnectionGranted granted => $"GitHub token for {granted.ExternalAccount?.Login}",
            AuthioConnectionRequired required => $"Connect GitHub first: {required.ConnectUri}",
            AuthioConnectionSelectionRequired selection => $"Pick one of {selection.Connections.Count} accounts",
            AuthioConnectionRefused refused => $"Refused: {refused.Error} ({refused.Code})",
            _ => "Unexpected"
        };
    }
}

Refusals throw AuthioProtocolException with Error (invalid_grant, ...), ErrorDescription, Authio's own ErrorCode and the HTTP StatusCode; the connected-accounts exchange returns its refusals as cases instead.

Device flow

var device = await tokens.RequestDeviceAuthorizationAsync(["openid", "offline_access"]);
Console.WriteLine($"Open {device.VerificationUri} and enter {device.UserCode}");
var tokensForDevice = await tokens.PollDeviceTokenAsync(device); // honours interval, slow_down (+5 s) and expiry

Introspection, revocation, userinfo

var result = await introspection.IntrospectAsync(accessToken);
if (!result.Active) { /* revoked, expired, session ended, subject suspended, delegation withdrawn */ }

await revocation.RevokeAsync(refreshToken, AuthioTokenTypes.RefreshToken);

var me = await userInfo.GetAsync(accessToken); // needs the openid scope
var orgId = me.AdditionalClaims?["org_id"].GetString();

Roles and permissions

Authio puts roles in JSON-valued claims (user_resource_access, realm_resource_access, client_resource_access, plan_resource_access, consumer_resource_access, secret_resource_access, system_resource_access) shaped {"role": {"resource": ["method"]}}, only when the token was granted the roles scope.

var access = AuthioResourceAccessParser.Parse(principal.Claims.Select(claim => (claim.Type, claim.Value)));

access.Roles;                        // ["billing-admin", "viewer"]
access.Grants("invoice", "write");   // Authio's rule: any role, any source, case-insensitive
access.Permissions["invoice"];       // {"read", "write"}
access.FromSource(AuthioResourceAccessSource.Consumer);

A malformed claim contributes nothing, exactly as in Authio's own check.

Webhooks

app.MapPost("/webhooks/authio", async (HttpRequest request) =>
{
    using var reader = new StreamReader(request.Body);
    var body = await reader.ReadToEndAsync();

    var verification = AuthioWebhookVerifier.Verify(
        request.Headers[AuthioWebhookHeaders.Signature], body, webhookSecret); // whsec_... with the prefix

    if (!verification.IsValid)
        return Results.Unauthorized();

    var webhookEvent = AuthioWebhookEventParser.Parse(body);

    switch (webhookEvent.Type)
    {
        case AuthioWebhookEventTypes.UserCreated:
            var user = webhookEvent.AsUser()!;
            break;
        case AuthioWebhookEventTypes.LoginFailed:
            var login = webhookEvent.AsLogin()!;
            break;
    }

    return Results.Ok();
});

Verification is constant-time, accepts any matching v1 among several, and refuses timestamps more than five minutes from now by default (pass tolerance to change it). Deduplicate on webhookEvent.Id.

Admin API

var page = await admin.ListUsersAsync("acme", new AuthioAdminUserQuery { SearchTerm = "alice", PageSize = 50 });
var user = await admin.CreateUserAsync("acme", new AuthioAdminCreateUserRequest
{
    UserName = "bob", Password = "S3cure!pass", Email = "bob@example.com", Roles = ["viewer"],
    SendEmail = false // an integration that tells people itself; true (the default) e-mails them
});
await admin.UpdateUserAsync("acme", user.ToUpdateRequest() with { FirstName = "Robert", SendEmail = false });
await admin.SetUserStatusAsync("acme", user.Id, AuthioUserStatus.Inactive);
await admin.DeleteUserAsync("acme", user.Id);                                       // no undo

await admin.AssignUserRolesAsync("acme", user.Id, ["editor"]);                     // keeps the roles it holds
await admin.UnassignUserRolesAsync("acme", user.Id, ["viewer"]);                   // keeps every other
await admin.AssignClientUserRolesAsync("acme", "web-app", user.Id, ["billing"]);   // a client's user
var assignable = await admin.ListAssignableUserRolesAsync();                        // what a user can be given

var report = await admin.ImportUsersAsync("acme", "web-app", new AuthioAdminImportUsersRequest
{
    Content = csv, DryRun = true // read the report, then run again with DryRun = false
});

var sessions = await admin.ListSessionsAsync("acme");
await admin.RevokeSessionsAsync("acme", sessions.Select(session => session.Id));
await admin.AddGroupMemberAsync("acme", "web-app", groupId, user.Id);

Role assignment uses the dedicated assigned-roles routes (an API that has them): it neither resends the profile nor e-mails the person. Giving roles is one call; taking them away is one DELETE per role and not atomic: every name is checked before the first call (an empty list, a blank name, . or .. throw ArgumentException and nothing is sent), but when a later call fails the roles already taken away stay removed, and the AuthioAdminApiException says which. ListUserRolesAsync lists only the roles of scope User; ListAssignableUserRolesAsync lists every role a user can be given (roles exclusive to a realm or a client can also be given within it). Deleting an account revokes its stored tokens, ends its sessions and frees its username and e-mail address; a console account is refused with 409. Failures throw AuthioAdminApiException with the HTTP status, the API's top-level ErrorCode, RequestId and Notifications; RevokeUserSessionAsync answers false only for the endpoint's own 404 (no such session), and a 404 with an ErrorCode (a route the API does not have: a wrong base URL, proxy or version) throws. Every realm, client, session and role name is sent as one path segment, so . and .. (which a URI would resolve one level up, onto another route) are refused with ArgumentException before any call. A realm's own client-credentials token is refused by the Admin API; the client authenticates with an account credential instead.

Updates replace everything. UpdateUserAsync, UpdateClientUserAsync, UpdateRoleAsync and UpdateSecretAsync are PUTs: whatever the request leaves out is reset, not kept. A user's first name, last name and phone number left null are cleared and its confirmation, two-factor and lockout flags left false are switched off (only Status, Roles and Claims left null are kept); a role's description left null is cleared, its claims are replaced and an omitted status would be Inactive; a credential with IsActive = false is revoked, a null Expiration removes its expiry and empty allowed lists take away its roles and scopes, and with them what it may do. Those members are required, so the compiler stops a request that would reset them by omission; build every update from what was last read with ToUpdateRequest() and change only what has to change with with { … }. The API refuses an expiration in the past, so an expired credential (IsExpired) is renamed or deactivated only with a new Expiration in the future, or null for none, in the same with; ToUpdateRequest() never changes it for you.

A client's people and roles

An integration that serves one application works through that client's routes, which a credential limited to the client (RestrictedClientId) reaches:

var editor = await admin.CreateClientRoleAsync("acme", "web-app", new AuthioAdminCreateRoleRequest
{
    Name = "editor",
    Claims = [new AuthioAdminRoleClaimRequest { ClaimType = "document", ClaimValue = "write" }] // resource / method
});
var person = await admin.CreateClientUserAsync("acme", "web-app", new AuthioAdminCreateUserRequest
{
    UserName = "dave", Password = "S3cure!pass", Email = "dave@example.com", SendEmail = false
});
await admin.AssignClientUserRolesAsync("acme", "web-app", person.Id, [editor.Name]);

var people = await admin.ListClientUsersAsync("acme", "web-app");                 // only the client's users
var current = await admin.GetClientUserAsync("acme", "web-app", person.Id);
await admin.UpdateClientUserAsync("acme", "web-app", current.ToUpdateRequest() with { FirstName = "David", SendEmail = false });
await admin.SetClientUserStatusAsync("acme", "web-app", person.Id, AuthioUserStatus.Inactive);
await admin.RevokeClientUserSessionAsync("acme", "web-app", person.Id, sessionId);
await admin.ResendClientUserConfirmationEmailAsync("acme", "web-app", person.Id);
await admin.DeleteClientUserAsync("acme", "web-app", person.Id);                 // from the whole realm

The client's routes reach only the users linked to it (created through it, imported into it or let into it); any other user of the realm answers 404. Reading and deleting need user:read / user:delete; creating, updating, the status, roles and the confirmation e-mail need client:write / client:update; ending a session needs client:delete. The account belongs to the realm, so DeleteClientUserAsync deletes the person from the whole realm, every client included; to take away only this client's access and keep the account, the API's route is DELETE realms/{realm}/clients/{client}/users/{userId}/access, which the SDK does not wrap.

ImportUsersAsync runs dry unless DryRun = false, and so does the API when the flag is left out. CSV columns are matched by name (roles separated by ;); JSON is an array of objects whose roles is an array and whose emailConfirmed and sendEmail are booleans.

Roles are created exclusive to a realm (CreateRealmRoleAsync, scope Realm) or to a client (CreateClientRoleAsync, scope Client, reaching only the tokens that client issues); a permission is a claim { claimType: resource, claimValue: method }. UpdateRoleAsync (start from role.ToUpdateRequest(); every claim is replaced, an exclusive role keeps its scope) and DeleteRoleAsync go through the account's roles/{id} route, which a credential limited to a realm or a client cannot reach.

Account credentials

Authio generates every credential that authenticates and shows its value once, prefixed by kind: ak_ (API key), pat_ (personal access token, acting as the person within the roles it was given) and cs_ (client secret, with a client_id of its own, cred_…). Put the value in AdminCredential and pick AdminAuthentication:

Credential AdminAuthentication On the wire
ak_… API key or pat_… personal access token ApiKey (default) x-api-key: ak_… / pat_…
ak_… or pat_… Bearer Authorization: Bearer ak_… / pat_…
pat_… personal access token PersonalAccessTokenExchange exchanged at the authio realm (grant_type=personal_access_token), then Bearer <token>
cs_… client secret ClientCredentials exchanged with client_credentials, HTTP Basic AdminClientId:cs_…, then Bearer <token>
cs_… client secret PlatformToken (legacy) the same exchange with client_id/client_secret form fields

The exchanged modes keep the token until TokenRefreshSkew before it expires, share it between concurrent calls, and on a 401 drop it and retry once with a new one. The validator refuses a credential whose prefix does not fit the mode (a cs_… presented as ApiKey, an ak_… exchanged), naming Authio:AdminCredential.

API key

{ "Authio": { "AdminCredential": "ak_…", "AdminAuthentication": "ApiKey" } }

Use "AdminAuthentication": "Bearer" for tooling that only knows how to send a bearer credential.

Personal access token, presented directly or exchanged (and narrowed) first:

{ "Authio": { "AdminCredential": "pat_…", "AdminAuthentication": "Bearer" } }
{
  "Authio": {
    "AdminCredential": "pat_…",
    "AdminAuthentication": "PersonalAccessTokenExchange",
    "AdminScopes": [ "admin:user:read" ]
  }
}

Client secret, exchanged under its own client id (shown beside it when created; AdminClientId defaults to the legacy authio, which is still accepted):

{
  "Authio": {
    "AdminCredential": "cs_…",
    "AdminAuthentication": "ClientCredentials",
    "AdminClientId": "cred_3f1c0c4e9b2a4d7e8f60a1b2c3d4e5f6",
    "AdminScopes": [ "admin:user:read", "admin:user:update" ]
  }
}
builder.Services.AddAuthioClient(options =>
{
    options.Authority = "https://auth.example.com";
    options.Realm = "acme";
    options.ClientId = "orders-service";
    options.ClientSecret = builder.Configuration["Authio:ClientSecret"];
    options.AdminCredential = builder.Configuration["Authio:AdminCredential"];
    options.AdminAuthentication = AuthioAdminAuthentication.ClientCredentials;
    options.AdminClientId = builder.Configuration["Authio:AdminClientId"]!;
    options.AdminScopes = [AuthioAdminScopeFormatter.Format(AuthioAdminPermissions.User, AuthioAdminMethods.Read)];
});

Administration scopes. Roles decide, scopes narrow: a token exchanged with AdminScopes (admin:{permission}:{method}, e.g. admin:user:read, admin:client:write) may do only what those name, within the credential's roles; asking for a scope the roles do not grant, or a malformed one (an admin: scope that does not name a permission and a method, e.g. admin:user), fails the exchange with invalid_scope (AuthioProtocolException), and a call outside the scopes answers 403. Scopes apply to the exchanged modes only; set on ApiKey or Bearer they fail validation. AuthioAdminPermissions, AuthioAdminMethods and AuthioAdminScopeFormatter build them.

Credentials limited to a realm or client. A credential created with RestrictedRealmId/RestrictedClientId reaches only the routes under that realm or client, and none of the account's own (roles, secrets); anything else answers 403 (AuthioAdminApiException). One limited to a client manages that client's people and roles with the methods above.

Renamed from AdminApiKey. The credential setting is AdminCredential (Authio:AdminCredential) for every credential type; AdminApiKey no longer exists. A configuration section bound with AddAuthioClient(configuration) that still holds AdminApiKey fails at start-up naming the new key; set in code, the old name simply does not compile.

Managing account credentials

var created = await admin.CreateSecretAsync(new AuthioAdminCreateSecretRequest
{
    Name = "nightly-sync",
    Type = AuthioSecretType.ClientSecret,      // no Value: Authio generates it
    AllowedRoleIds = [userReaderRoleId],
    RestrictedRealmId = acmeRealmId
});
Store(created.Value!, created.ClientId!);     // the only answer that carries the value

var rotated = await admin.RotateSecretAsync(created.Id); // the old value stops working at once
var all = await admin.ListSecretsAsync();                 // values never come back, only ValueHint
var current = await admin.GetSecretAsync(created.Id);
await admin.UpdateSecretAsync(created.Id, current.ToUpdateRequest() with
{
    IsActive = false,
    Expiration = current.IsExpired ? null : current.Expiration // the API refuses a past date
});
await admin.DeleteSecretAsync(created.Id);

A credential's AllowedRoles name each role by Role.Id, Role.Name and Role.Description only (AuthioAdminRoleReference); read a role's scope, status and permissions from the role listings.

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 (2)

Showing the top 2 NuGet packages that depend on Authio.Client:

Package Downloads
Authio.AspNetCore

Protect an ASP.NET Core API with Authio: JWT bearer validation from the realm's discovery document, optional introspection (active check or opaque tokens), flattened Authio roles and permissions, scope / role / permission / organization / step-up / client policies with attributes and minimal API helpers, a typed principal, a signed webhook receiver, and token forwarding and on-behalf-of for downstream calls.

Authio.AspNetCore.Bff

Backend-for-Frontend for Authio: lets a browser SPA (React, Angular, Vue...) sign in with Authio without ever holding a token. Cookie + OpenID Connect (code flow, PKCE, RFC 9207 iss check), server-side sessions, refresh-token rotation with single-flight refresh, session liveness by introspection, login/logout/user endpoints, CSRF-protected local APIs and a YARP-based remote API proxy that attaches the user's token.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.6.0 53 10/9/2026
0.5.1 66 10/8/2026
0.5.0 72 10/8/2026
0.4.0 102 10/6/2026
0.3.0 125 10/5/2026
0.2.0 123 10/4/2026
0.1.0 124 10/4/2026