Authio.Client 0.8.0

There is a newer version of this package available.
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
                    
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.8.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Authio.Client" Version="0.8.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.8.0
                    
#r "nuget: Authio.Client, 0.8.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.8.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.8.0
                    
Install as a Cake Addin
#tool nuget:?package=Authio.Client&version=0.8.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; the signing keys (jwks_uri) cached the same way and read again when a token names an unknown kid.
  • Interactive login: the authorization URL (the PKCE, state and nonce checks the client is configured for, the response mode, Authio's resource, organization, prompt, max_age, acr_values, login_hint, id_token_hint, ui_locales, display, idp_hint), the callback (state, RFC 9207 iss, code redemption, ID token validation with nonce, at_hash and max_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/HS512 with 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, and private_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 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>",
    "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.

  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",
        "TokenEndpointAuthMethod": "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.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 its aud. Only the client the resource server names as its introspection client, the API itself, is told it is active. The client that requested it gets active: false by 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.AspNetCore with Introspection = 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 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 (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.

Version Downloads Last Updated
0.9.0 75 10/14/2026
0.8.1 103 10/14/2026
0.8.0 100 10/14/2026
0.7.0 113 10/13/2026
0.6.0 133 10/9/2026
0.5.1 128 10/8/2026
0.5.0 118 10/8/2026
0.4.0 117 10/6/2026
0.3.0 126 10/5/2026
0.2.0 123 10/4/2026
0.1.0 124 10/4/2026