Authio.Client
0.5.1
See the version list below for details.
dotnet add package Authio.Client --version 0.5.1
NuGet\Install-Package Authio.Client -Version 0.5.1
<PackageReference Include="Authio.Client" Version="0.5.1" />
<PackageVersion Include="Authio.Client" Version="0.5.1" />
<PackageReference Include="Authio.Client" />
paket add Authio.Client --version 0.5.1
#r "nuget: Authio.Client, 0.5.1"
#:package Authio.Client@0.5.1
#addin nuget:?package=Authio.Client&version=0.5.1
#tool nuget:?package=Authio.Client&version=0.5.1
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();
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.
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.