Authio.Client
0.8.0
See the version list below for details.
dotnet add package Authio.Client --version 0.8.0
NuGet\Install-Package Authio.Client -Version 0.8.0
<PackageReference Include="Authio.Client" Version="0.8.0" />
<PackageVersion Include="Authio.Client" Version="0.8.0" />
<PackageReference Include="Authio.Client" />
paket add Authio.Client --version 0.8.0
#r "nuget: Authio.Client, 0.8.0"
#:package Authio.Client@0.8.0
#addin nuget:?package=Authio.Client&version=0.8.0
#tool nuget:?package=Authio.Client&version=0.8.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; the signing keys
(
jwks_uri) cached the same way and read again when a token names an unknownkid. - Interactive login: the authorization URL (the PKCE,
stateandnoncechecks the client is configured for, the response mode, Authio'sresource,organization,prompt,max_age,acr_values,login_hint,id_token_hint,ui_locales,display,idp_hint), the callback (state, RFC 9207iss, code redemption, ID token validation withnonce,at_hashandmax_age), the end-session URL, and the authorization URL that satisfies a resource server's step-up challenge. - Tokens Authio issues to the client: ID tokens, back-channel logout tokens, UserInfo JWTs and the access tokens
it hands over (
IAuthioAccessTokenValidator) validated with the algorithm the client is configured with (by default the asymmetric ones discovery announces;HS256/HS384/HS512with the client secret); nested JWEs decrypted with the client's own key behind configurable allowlists (RSA-OAEP, RSA-OAEP-256; RSA1_5 only on request). - Every token grant Authio supports: client credentials, authorization code with PKCE, refresh (with rotation and
RFC 8707
resource), 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), as the client is registered (TokenEndpointAuthMethod). - 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>",
"TokenEndpointAuthMethod": "Basic",
"Checks": "Pkce, State, Nonce",
"CodeChallengeMethod": "S256",
"ClientEncryptionKey": "<the client's private encryption key, only when Authio encrypts its tokens>",
"AllowLegacyEncryptionAlgorithms": false,
"ClockSkew": "00:00:30",
"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. TokenEndpointAuthMethod is Basic
(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);
left out, it follows the credentials given: a secret means Basic, a private key PrivateKeyJwt, neither
None. Invalid settings fail at start-up with a message naming the key, e.g. Authio:Realm is required when Authio:Issuer is not set.
ClientId is needed by whatever names the client: the token, introspection, revocation and device requests, the
sign-in, and the ID token, logout token and UserInfo checks. It is required at start-up alongside a client credential
or another client setting (TokenEndpointAuthMethod, Checks, ResponseMode, IdTokenSignedResponseAlg,
UserinfoSignedResponseAlg); otherwise a call that needs it throws InvalidOperationException naming the key. Settings
that only say where the realm is (an API validating access tokens locally) start without one.
Mirroring the client's configuration
The library hard-codes nothing the Authio console lets a client configure: each setting is an option, and an option
left out is off, as in NextAuth. Authio refuses what does not match the client (with an error naming the setting, e.g.
This client is registered to authenticate with 'client_secret_post') and accepts the extra (a PKCE challenge, a
state or a nonce it does not require).
| In the console | Option | Left out |
|---|---|---|
| PKCE required | Checks includes Pkce |
No challenge is sent: a client requiring PKCE refuses the sign-in |
| PKCE methods allowed | CodeChallengeMethod: S256 or Plain |
S256 |
| (protection the app wants) | Checks includes State and/or Nonce |
Neither is sent nor checked |
| Token endpoint authentication | TokenEndpointAuthMethod |
Follows the credentials given |
| Signing algorithm | IdTokenSignedResponseAlg, UserinfoSignedResponseAlg |
The asymmetric algorithms discovery announces; HS* refused. UserInfo follows the ID token setting |
| Access token signing algorithm | AccessTokenSignedResponseAlg (one or several) |
The asymmetric algorithms discovery announces; HS* refused |
| Token encryption | ClientEncryptionKey, EncryptionAlgorithms, EncryptionMethods |
Nothing is decrypted |
| (response mode, realm-wide) | ResponseMode: Query or WebMessage |
None is sent (Authio answers in the query) |
Checks is a flags enum (AuthioChecks.Pkce | AuthioChecks.State | AuthioChecks.Nonce, or "Pkce, State, Nonce" in
configuration). Only what is marked is generated and verified: with None, the authorization URL carries no
challenge, state or nonce, and the callback checks none of them. A request that sets State, Nonce or
CodeVerifier for a check that is off is refused.
HS256, HS384 and HS512 are verified with the client secret (OpenID Connect Core §10.1), as Authio signs them:
its UTF-8 octets, at least 32, 48 or 64 bytes (RFC 7518 §3.2, AuthioHmacMinimumSecretBytes). A shorter
ClientSecret fails at start-up; one from ClientSecretProvider fails when a token is verified, with
InvalidOperationException. The access token has its own setting, AccessTokenSignedResponseAlg (the refresh token
is always signed with the server's key): an HS* access token can be validated only where the client secret is, so
use it only when the API is the client's own, and let every other API introspect such tokens. Prefer an asymmetric
algorithm otherwise.
AccessTokenSignedResponseAlg binds as one value ("HS256") or a list (["RS256", "HS256"]).
IAuthioAccessTokenValidator.ValidateAsync(accessToken) reads an access token the client received: decrypted,
signature, iss and exp checked (not aud, which is the API's), null for an opaque token or an encrypted one
without ClientEncryptionKey.
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"];
});
A rotated secret without a restart. ClientSecretProvider (code only) is called for every request that
authenticates the client, and wins over ClientSecret when it answers:
builder.Services.AddAuthioClient(configuration, options => options.ClientSecretProvider = () => secrets.Current("orders-service"));
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 TokenEndpointAuthMethod = PrivateKeyJwt (or a private key and no secret) 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", "TokenEndpointAuthMethod": "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.TokenEndpointAuthMethod = AuthioTokenEndpointAuthMethod.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
TokenEndpointAuthMethod 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 forReports = await tokens.RefreshTokenAsync(refreshed.RefreshToken!, resource: "https://reports.example.com");
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.ErrorCode}): {refused.ErrorDescription}",
_ => "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, each
with Status (Granted, Required, SelectionRequired, Refused), ErrorCode and ErrorDescription, and
Provider on Required and SelectionRequired.
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
Interactive login
IAuthioLoginClient signs a user in with the authorization code flow, for an application that holds the session
itself (a desktop or mobile app, a CLI with a loopback redirect, a web stack other than the BFF):
public sealed class SignIn(IAuthioLoginClient login, ISessionStore sessions)
{
public async Task<Uri> StartAsync()
{
var redirect = await login.CreateAuthorizationAsync(new AuthioAuthorizationRequest
{
RedirectUri = "https://app.example.com/callback",
Scopes = ["openid", "profile", "offline_access", "roles"],
Resource = "https://orders.example.com", // the API the access token is for (RFC 8707)
Organization = "acme-europe",
Prompt = AuthioPrompt.SelectAccount,
UiLocales = "pt-BR en"
});
sessions.Save(redirect.State); // state, nonce, PKCE verifier (those Checks marks): keep server-side until the callback
return redirect.Url;
}
public async Task<AuthioLoginResult> CallbackAsync(Uri callbackUrl)
{
var result = await login.HandleCallbackAsync(callbackUrl, sessions.Take());
// result.Tokens: access, refresh and ID tokens; result.IdToken: validated (Subject, SessionId, Acr, …)
return result;
}
public Task<Uri> SignOutAsync(string idToken)
=> login.CreateEndSessionUrlAsync(new AuthioEndSessionRequest
{
IdTokenHint = idToken,
PostLogoutRedirectUri = "https://app.example.com/signed-out"
});
}
CreateAuthorizationAsync generates, unless given, what Checks marks: a state, a nonce and a 43-character PKCE
verifier whose challenge uses CodeChallengeMethod (S256 by default). HandleCallbackAsync refuses a callback
whose state differs from the one sent (invalid_state) or whose iss is missing or another issuer
(invalid_issuer, RFC 9207), throws the error Authio sent back (access_denied, login_required, invalid_request
for a missing PKCE challenge, …) as AuthioProtocolException, redeems the code (with the verifier, when PKCE is on)
and validates the ID token: signature with IdTokenSignedResponseAlg (by default the asymmetric algorithms discovery
announces, with the cached key set), iss, aud the client id, exp within ClockSkew, payload typ ID, nonce when it was sent,
at_hash with the hash of the signing algorithm and auth_time within MaxAge. A refused ID token throws
AuthioTokenValidationException whose Failure names why (InvalidNonce, InvalidAlgorithm, Expired, …). For a
popup, send Display = "popup" and ResponseMode = AuthioResponseMode.WebMessage and pass the posted parameters to the
dictionary overload.
IAuthioIdTokenValidator validates an ID token on its own (e.g. one from a refresh).
Step-up
A resource server that needs a stronger or more recent sign-in answers 401 with
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="2 3", max_age=300 (RFC 9470;
Authio.AspNetCore does). Read it and sign the user in again with what it asks:
if (AuthioStepUpChallengeParser.TryParse(response, out var challenge))
{
var redirect = await login.CreateStepUpAuthorizationAsync(new AuthioAuthorizationRequest { RedirectUri = callback }, challenge!);
// acr_values and max_age from the challenge; prompt=login when max_age is 0
}
Encrypted tokens (JWE)
A client whose encryption key source in Authio is its own receives its ID tokens (and, when configured, its access
tokens and UserInfo answers) as nested JWEs. Give the private half of the key, as Authio handed it over (a private JWK)
or as a PEM, in ClientEncryptionKey, or the path of a file holding it in ClientEncryptionKeyPath (read again when
the file changes). Without a key no JWE is decrypted, and one received is refused with an error naming the setting.
The key management algorithms accepted are RSA-OAEP and RSA-OAEP-256 (which IdentityModel does not implement; this
package adds it), the content encryption A128CBC-HS256, A192CBC-HS384 and A256CBC-HS512; narrow them to the ones
the client is configured with in EncryptionAlgorithms and EncryptionMethods (which also takes A128GCM,
A192GCM and A256GCM, accepted only when listed). RSA1_5 is refused
(AuthioTokenValidationFailure.LegacyAlgorithm) unless AllowLegacyEncryptionAlgorithms is true, even when listed.
TokenValidationParameters.UseAuthioDecryption(...) applies the same keys and allowlists to your own IdentityModel
validation.
Back-channel logout
Authio posts a logout_token to a client's back-channel logout URI when a session ends.
IAuthioLogoutTokenValidator validates it (signature with the ID token algorithm and iss/aud/exp, header typ logout+jwt, the
back-channel logout event, no nonce, a sub and/or a sid) and returns the session and user whose sessions must
end; Authio.AspNetCore.Bff does this for you.
For front-channel logout, Authio loads the client's front-channel logout URI in an iframe, with iss and sid
when the client requires the session. ValidateFrontChannelLogout(requestUrl) checks the iss against the realm,
refuses a sid without its iss, and returns the SessionId to end (null: end the session of the browser that
loaded the page).
app.MapPost("/backchannel-logout", async (HttpRequest request, IAuthioLogoutTokenValidator validator, MySessions sessions) =>
{
var form = await request.ReadFormAsync();
var logout = await validator.ValidateAsync(form["logout_token"].ToString()); // AuthioTokenValidationException when refused
await sessions.EndAsync(logout.SessionId, logout.Subject);
return Results.Ok();
});
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; a signed or encrypted answer is validated
var orgId = me.AdditionalClaims?["org_id"].GetString();
Who can introspect a token
Authio answers an introspection request according to who asks (RFC 7662 §2.2: a caller that is not allowed to
introspect a particular token gets active: false; §4: the server authorizes who may introspect):
- A token requested for a resource (
resource=https://orders.example.com, RFC 8707) has that resource as itsaud. Only the client the resource server names as its introspection client, the API itself, is told it is active. The client that requested it getsactive: falseby design: to that client the access token is opaque (RFC 6749 §1.4), something it sends to the API and does not read. Let the API introspect it (Authio.AspNetCorewithIntrospection = ActiveCheck). - A token requested without a resource is addressed to the client itself, which introspects it normally (a worker checking its own token, a BFF checking the session's refresh token).
So active: false from IntrospectAsync on a token your own client requested for a resource does not mean the token
was revoked; it means your client is not the one Authio reports it to.
A public client (TokenEndpointAuthMethod = None) introspects too, with its client_id alone: Authio accepts it and
answers as for any client, about the tokens addressed to it.
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();
});
Every event type has a typed accessor exposing every field Authio sends: AsUser, AsLogin (with Scopes,
AuthenticationMethods, Acr, IdentityProvider, ConsumerId, ConsumerName), AsUserLockedOut,
AsTokenRevoked, AsKeysRotated, AsTokenReuseDetected, AsAdminResource (with ParentId, ActorType,
ActorCredentialId, ActorCredentialName, ActorPlatformOperator, Details), AsUserSuspension,
AsAttackDetected, AsCredentialCompromised and AsPing. An unknown type still parses, with its Data as raw JSON.
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 (never the envelope's statusCode, the same
number on every answer), the API's top-level ErrorCode, RequestId (or the X-Request-Id header),
Notifications and RetryAfterSeconds. A 429 or 503 with a Retry-After of at most a minute is retried once
after that wait for an idempotent call (GET, PUT, DELETE); AuthioAdminRequestContext.UseRequestId(id) sends
X-Request-Id on the calls of a scope. IHttpClientBuilder.AddAuthioAccountCredential() gives any HttpClient the same
authentication and retry, to call an Admin API route this client does not cover yet; 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 (3)
Showing the top 3 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. |
|
|
Authio.Testing
Test an application that uses Authio without an Authio server: an in-memory fake Authio served through an HttpMessageHandler (discovery, JWKS, the authorization endpoint with PKCE and web_message, every token grant, ID tokens with nonce and at_hash, JWE to a client's own key, introspection, revocation, userinfo, end session, back-channel logout delivery, device flow and Admin API stubs), a token factory and builder for crafting any token, and a manual clock. |
GitHub repositories
This package is not used by any popular GitHub repositories.