Authio.Client
0.5.0
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
<PackageReference Include="Authio.Client" Version="0.5.0" />
<PackageVersion Include="Authio.Client" Version="0.5.0" />
<PackageReference Include="Authio.Client" />
paket add Authio.Client --version 0.5.0
#r "nuget: Authio.Client, 0.5.0"
#:package Authio.Client@0.5.0
#addin nuget:?package=Authio.Client&version=0.5.0
#tool nuget:?package=Authio.Client&version=0.5.0
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, andprivate_key_jwt(a signed assertion instead of a secret). - Introspection, revocation and userinfo.
- Machine-to-machine calls: a client-credentials token cache and an
HttpClienthandler that adds the token, renews it before expiry and retries once on401. - Authio roles: a parser for the
*_resource_accessclaims 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.
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.pemConfigure 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" } }ClientPrivateKeytakes the PEM text instead (PKCS#8, PKCS#1 or SEC1; one-line text with escaped\nis accepted). The text or file may also hold a certificate or theEC PARAMETERSOpenSSL 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 (notsecp256k1or a Brainpool curve of the same size), and the messages name the setting the key came from.ClientSigningAlgorithmdefaults toRS256for RSA and toES256/ES384/ES512for the curve;PS256–PS512andRS384/RS512are accepted too. A key file is read again when it is rewritten, so a rotated mounted secret is picked up without a restart.ClientSecretis not needed and never sent.Register the public half on the client in the Authio console (inline JWK Set, or a
jwks_uriyou publish), under the samekid. 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 | 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
- Microsoft.Extensions.Caching.Memory (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Http (>= 10.0.10)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
- Microsoft.IdentityModel.JsonWebTokens (>= 8.19.2)
-
net8.0
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Http (>= 8.0.1)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.2)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 8.0.0)
- Microsoft.IdentityModel.JsonWebTokens (>= 7.1.2)
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.