Authio.Client 0.5.1

There is a newer version of this package available.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Authio.Client" Version="0.5.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Authio.Client" Version="0.5.1" />
                    
Directory.Packages.props
<PackageReference Include="Authio.Client" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Authio.Client --version 0.5.1
                    
#r "nuget: Authio.Client, 0.5.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Authio.Client@0.5.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Authio.Client&version=0.5.1
                    
Install as a Cake Addin
#tool nuget:?package=Authio.Client&version=0.5.1
                    
Install as a Cake Tool

Authio.Client

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

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

Configuration

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

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

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

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

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

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

Client authentication with a private key (private_key_jwt)

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

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

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

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

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

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

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

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

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

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

Calling another API as this service (client credentials)

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

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

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

Token grants

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

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

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

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

Device flow

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

Introspection, revocation, userinfo

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

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

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

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.

Roles and permissions

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

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

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

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

Webhooks

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

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

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

    var webhookEvent = AuthioWebhookEventParser.Parse(body);

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

    return Results.Ok();
});

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

Admin API

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

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

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

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

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

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

A client's people and roles

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

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

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

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

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

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

Account credentials

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

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

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

API key

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

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

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

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

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

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

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

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

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

Managing account credentials

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

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

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

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

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

Package Downloads
Authio.AspNetCore

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

Authio.AspNetCore.Bff

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

GitHub repositories

This package is not used by any popular GitHub repositories.

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