LowCodeHub.Keycloak.Client 0.2.0

dotnet add package LowCodeHub.Keycloak.Client --version 0.2.0
                    
NuGet\Install-Package LowCodeHub.Keycloak.Client -Version 0.2.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="LowCodeHub.Keycloak.Client" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.Keycloak.Client" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.Keycloak.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 LowCodeHub.Keycloak.Client --version 0.2.0
                    
#r "nuget: LowCodeHub.Keycloak.Client, 0.2.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 LowCodeHub.Keycloak.Client@0.2.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=LowCodeHub.Keycloak.Client&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.Keycloak.Client&version=0.2.0
                    
Install as a Cake Tool

LowCodeHub.Keycloak.Client

A typed Keycloak client SDK for .NET. Full CRUD for users, groups, roles, sessions, clients, federated identities, and authentication — with automatic admin token management, stampede-safe caching, and built-in HTTP resilience.

Every call returns an Outcome<T> from LowCodeHub.Outcomes: either the value or a ProblemDetails carrying Keycloak's HTTP status and a stable error code. Not-found lookups, conflicts, rejected logins and other Keycloak error responses are returned, not thrown.

NuGet License: MIT

Why This Library?

The facade includes realm management through keycloak.Realms, client provisioning through keycloak.Clients, and role provisioning through keycloak.Roles.

Feature LowCodeHub.Keycloak.Client Raw Keycloak REST API Other Keycloak SDKs
Admin token management Automatic — cached, stampede-safe, refreshed before expiry Manual token lifecycle Varies
API surface Facade pattern — keycloak.Users.…, keycloak.Groups.… 200+ raw endpoints Flat god-interface
User CRUD Typed methods — create, update, delete, search, attributes Raw HTTP calls Partial
Group management Full — CRUD, membership, sub-groups, count Raw HTTP calls Partial
Role assignment Realm + client roles — assign, remove, effective, available Raw HTTP calls Partial
Session management List, logout, delete Raw HTTP calls Rarely supported
User authentication Login, refresh, logout, userinfo, introspect Raw HTTP calls Varies
Federated identities Get, link, unlink Raw HTTP calls Rarely supported
Impersonation One method Raw HTTP calls Rarely supported
Client secrets Get + regenerate Raw HTTP calls Rarely supported
HTTP resilience Built-in — retries, circuit breakers, timeouts Manual Manual
DI integration One extension method — IValidateOptions + startup validation Manual HttpClient Varies
Error handling Outcome<T> — Keycloak errors as ProblemDetails with status + error code, ready for ToHttpResult() Parse error bodies yourself Exceptions

Installation

dotnet add package LowCodeHub.Keycloak.Client

Quick Start

using LowCodeHub.Keycloak.Client.Extensions;

// Binds the "Keycloak" section (see Configuration below); validated at startup.
builder.Services.AddKeycloakClient(builder.Configuration);
using LowCodeHub.Keycloak.Client;
using LowCodeHub.Keycloak.Client.Models;
using LowCodeHub.Outcomes;
using Microsoft.AspNetCore.Mvc;

// Minimal API: an Outcome renders as 200/204 or as the problem (status + errorCode preserved).
app.MapGet("/users/{id}", (string id, IKeycloakClient keycloak, CancellationToken ct) =>
    keycloak.Users.GetByIdAsync(id, ct).ToHttpResult());                        // 200 user, or 404 Keycloak.UserNotFound

app.MapPost("/users", (CreateUserRequest request, IKeycloakClient keycloak, CancellationToken ct) =>
    keycloak.Users.CreateAsync(request, ct)
        .ToHttpResult(id => TypedResults.Created($"/users/{id}", new { id })));  // 201, or e.g. 409 from Keycloak

app.MapDelete("/users/{id}", (string id, IKeycloakClient keycloak, CancellationToken ct) =>
    keycloak.Users.DeleteAsync(id, ct).ToHttpResult());                         // 204, or problem

// In code: switch on the outcome (exhaustive, no discard arm needed).
Outcome<KeycloakUser> result = await keycloak.Users.GetByEmailAsync(email, ct);
string message = result switch
{
    KeycloakUser user => $"Found {user.Username}",
    ProblemDetails p when p.HasErrorCode(KeycloakErrorCodes.UserNotFound) => "No such user",
    ProblemDetails p => $"Keycloak failed: {p.Status} {p.GetErrorCode()}",
};

Inject IKeycloakClient and access resource-scoped sub-clients: .Users, .Groups, .Roles, .Clients, .Realms, .Sessions, .Authentication, .Impersonation, .FederatedIdentities.

Return types follow one rule: a call that returns data returns Task<Outcome<T>> (lists as Outcome<IReadOnlyList<T>>, creates as Outcome<string> holding the new id); a call with no data returns Task<Outcome<Success>>.


Table of Contents


Configuration

Code-Based Configuration

builder.Services.AddKeycloakClient(options =>
{
    options.KeycloakBaseUrl = "https://keycloak.example.com";
    options.Realm = "my-realm";
    options.Credentials = new()
    {
        ClientId = "admin-cli",
        ClientSecret = "your-client-secret"  // null for public clients
    };
});

Configuration from appsettings.json

builder.Services.AddKeycloakClient(builder.Configuration);
{
  "Keycloak": {
    "KeycloakBaseUrl": "https://keycloak.example.com",
    "Realm": "my-realm",
    "AdminTokenRealm": "master",
    "Credentials": {
      "ClientId": "admin-cli",
      "ClientSecret": "your-client-secret"
    }
  }
}

Custom section name, plus code overrides applied after binding (e.g. a secret from a vault):

builder.Services.AddKeycloakClient(builder.Configuration, sectionName: "Auth:Keycloak",
    configureOptions: o => o.Credentials.ClientSecret = secrets.KeycloakSecret);

Calling AddKeycloakClient more than once is safe: later calls only add their options configuration.

Options are validated at startup via IValidateOptions<KeycloakClientOptions>. If required fields are missing, the app throws OptionsValidationException at app.Run().

All Options

Option Type Required Description
KeycloakBaseUrl string Yes Base URL of your Keycloak server
Realm string Yes Keycloak realm name
AdminTokenRealm string? No Realm used to acquire admin access tokens. Defaults to Realm.
Credentials.ClientId string Yes Client ID for API authentication
Credentials.ClientSecret string? No Client secret. null for public clients.

Errors and input validation

Expected failures come back as a ProblemDetails inside the Outcome<T>; the error code is in Extensions["errorCode"] (read it with problem.GetErrorCode() / problem.HasErrorCode(...)). Codes are constants on KeycloakErrorCodes:

Code Status When
Keycloak.UserNotFound 404 Users.GetByIdAsync 404; GetByUsernameAsync / GetByEmailAsync / GetByAttributeAsync matched nothing; Clients.GetServiceAccountUserAsync 404 (unknown client or no service-account user)
Keycloak.GroupNotFound 404 Groups.GetByIdAsync 404; no group with the name (GetByNameAsync, GetMembersByGroupNameAsync, the …ByNameAsync membership calls)
Keycloak.RoleNotFound 404 Roles.GetRealmByNameAsync / GetClientByNameAsync 404
Keycloak.ClientNotFound 404 Clients.GetByClientIdAsync matched nothing
Keycloak.RealmNotFound 404 Realms.GetByNameAsync 404
Keycloak.AdminTokenUnavailable 503 The admin access token could not be acquired (token endpoint rejected the service credentials or returned an unusable token); the admin API is not called
Keycloak.MissingLocation 500 A create succeeded but Keycloak sent no Location header with the new id
Keycloak.{error} Keycloak's status Any other error response whose body has an OAuth-style error token, e.g. Keycloak.invalid_grant from Authentication.LoginAsync
Keycloak.{status} Keycloak's status Any other error response, e.g. Keycloak.409 for "User exists with same username"
Http.EmptyBody / Http.InvalidBody 500 A 2xx response whose body was empty or could not be read (codes from LowCodeHub.Outcomes)
  • The problem's Detail is Keycloak's errorMessage, else error: error_description, truncated to 512 characters (else the reason phrase). Raw response bodies, query strings, usernames and e-mails are never copied into a problem; not-found details are generic ("The user was not found.").
  • Only identifier-like error values (letters, digits, _, -, .; up to 64 characters) become Keycloak.{error}; free-text values such as "User not found" fall back to Keycloak.{status} and are kept in Detail.
  • The status is always Keycloak's own, so ToHttpResult() forwards it unchanged. Use ToHttpResult(onSuccess) or a switch when a Keycloak status should not reach your API's clients as-is.
  • Still thrown: transport failures (HttpRequestException), timeouts, cancellation, and invalid arguments.
  • Ids and names used as URL path segments (user id, group id, role name, client UUID, realm, provider, session id) are percent-encoded and validated: empty, . and .. throw ArgumentException before any request is sent, so Users.DeleteAsync("..") can never resolve to DELETE /admin/realms/{realm}.

Realms

keycloak.Realms.*

var realms = await keycloak.Realms.GetAllAsync(ct);
var realm = await keycloak.Realms.GetByNameAsync("my-realm", ct);

await keycloak.Realms.CreateAsync(new CreateRealmRequest
{
    Realm = "my-realm",
    Enabled = true,
    ResetPasswordAllowed = true,
    VerifyEmail = true
}, ct);

await keycloak.Realms.UpdateAsync("my-realm", new UpdateRealmRequest
{
    DisplayName = "My Realm",
    RegistrationAllowed = true
}, ct);

Users

keycloak.Users.*

Create, Update, Delete

// Create
Outcome<string> created = await keycloak.Users.CreateAsync(new CreateUserRequest
{
    Username = "john.doe",
    Email = "john@example.com",
    FirstName = "John",
    LastName = "Doe",
    Enabled = true,
    RequiredActions = ["UPDATE_PASSWORD"],
    Credentials = [new UserCredential { Value = "temp-password", Temporary = true }],
    Groups = ["developers"],
    Attributes = new() { ["department"] = ["engineering"] }
}, ct);
if (created is not string userId) return;   // a ProblemDetails, e.g. 409 Keycloak.409 when the username exists

// Update (all fields optional)
Outcome<Success> updated = await keycloak.Users.UpdateAsync(userId, new UpdateUserRequest
{
    FirstName = "Jonathan",
    Email = "new@example.com"
}, ct);

// Delete
Outcome<Success> deleted = await keycloak.Users.DeleteAsync(userId, ct);

Query

// Lookups never return null: a miss is a 404 problem with Keycloak.UserNotFound.
Outcome<KeycloakUser> user = await keycloak.Users.GetByIdAsync(userId, ct);
Outcome<KeycloakUser> byName = await keycloak.Users.GetByUsernameAsync("john.doe", ct);
Outcome<KeycloakUser> byEmail = await keycloak.Users.GetByEmailAsync("john@example.com", ct);
Outcome<KeycloakUser> byAttr = await keycloak.Users.GetByAttributeAsync("employeeId", "12345", ct);
Outcome<IReadOnlyList<KeycloakUser>> many = await keycloak.Users.GetByAttributeManyAsync("department", "engineering", ct);
Outcome<int> count = await keycloak.Users.GetCountAsync(ct);

// Map/Bind chain without unwrapping (problems pass through):
Outcome<string> email = await keycloak.Users.GetByIdAsync(userId, ct).Map(u => u.Email ?? "");

Outcome<IReadOnlyList<KeycloakUser>> found = await keycloak.Users.SearchAsync(new UserSearchRequest
{
    Search = "john",
    EmailVerified = true,
    Enabled = true,
    First = 0,
    Max = 20,
    Exact = false
}, ct);

var all = await keycloak.Users.GetAllAsync(first: 0, max: 100, ct);

Enable / Disable

await keycloak.Users.EnableAsync(userId, ct);
await keycloak.Users.DisableAsync(userId, ct);

Credentials & Email Actions

await keycloak.Users.ResetPasswordAsync(userId, "new-password", temporary: true, ct);
await keycloak.Users.SendPasswordResetEmailAsync(userId, cancellationToken: ct);
await keycloak.Users.SendVerifyEmailAsync(userId, cancellationToken: ct);
await keycloak.Users.ExecuteActionsEmailAsync(userId, ["UPDATE_PASSWORD", "VERIFY_EMAIL"], cancellationToken: ct);
await keycloak.Users.UpdateAttributesAsync(userId,
    new Dictionary<string, List<string>> { ["department"] = ["engineering"] }, ct);

Cross-Resource Shortcuts

// Groups (also available on keycloak.Groups)
var groups = await keycloak.Users.GetGroupsAsync(userId, ct);
await keycloak.Users.AddToGroupAsync(userId, groupId, ct);
await keycloak.Users.AddToGroupByNameAsync(userId, "developers", ct); // Keycloak.GroupNotFound if no such group
await keycloak.Users.RemoveFromGroupAsync(userId, groupId, ct);

// Realm roles (also available on keycloak.Roles)
var roles = await keycloak.Users.GetRealmRolesAsync(userId, ct);
var effective = await keycloak.Users.GetEffectiveRealmRolesAsync(userId, ct);
var available = await keycloak.Users.GetAvailableRealmRolesAsync(userId, ct);
await keycloak.Users.AssignRealmRolesAsync(userId, [adminRole], ct);
await keycloak.Users.RemoveRealmRolesAsync(userId, [adminRole], ct);

// Client roles (also available on keycloak.Roles)
var clientRoles = await keycloak.Users.GetClientRolesAsync(userId, clientUuid, ct);
await keycloak.Users.AssignClientRolesAsync(userId, clientUuid, [editorRole], ct);

// Federated identities (also available on keycloak.FederatedIdentities)
var identities = await keycloak.Users.GetFederatedIdentitiesAsync(userId, ct);
await keycloak.Users.LinkFederatedIdentityAsync(userId, "google", identity, ct);
await keycloak.Users.UnlinkFederatedIdentityAsync(userId, "google", ct);

// Sessions (also available on keycloak.Sessions)
var sessions = await keycloak.Users.GetSessionsAsync(userId, ct);
await keycloak.Users.LogoutAsync(userId, ct);

Groups

keycloak.Groups.*

// CRUD
var groups = await keycloak.Groups.GetAllAsync(ct);
var byId = await keycloak.Groups.GetByIdAsync(groupId, ct);
var byName = await keycloak.Groups.GetByNameAsync("developers", ct);   // Keycloak.GroupNotFound on a miss
Outcome<int> count = await keycloak.Groups.GetCountAsync(ct);

if (await keycloak.Groups.CreateAsync(
    new CreateGroupRequest { Name = "developers" }, ct) is not string groupId) return;   // a ProblemDetails otherwise
await keycloak.Groups.UpdateAsync(groupId,
    new CreateGroupRequest { Name = "engineers" }, ct);
await keycloak.Groups.DeleteAsync(groupId, ct);

// Members
var members = await keycloak.Groups.GetMembersAsync(groupId, first: 0, max: 50, ct);
var membersByName = await keycloak.Groups.GetMembersByGroupNameAsync("developers", ct);

// Sub-groups
var children = await keycloak.Groups.GetSubGroupsAsync(parentGroupId, ct);
Outcome<string> childId = await keycloak.Groups.CreateSubGroupAsync(parentGroupId,
    new CreateGroupRequest { Name = "frontend" }, ct);   // match on string / ProblemDetails like groupId above

// Membership (cross-resource — also on keycloak.Users)
await keycloak.Groups.AddMemberAsync(userId, groupId, ct);
await keycloak.Groups.AddMemberByNameAsync(userId, "developers", ct);
await keycloak.Groups.RemoveMemberAsync(userId, groupId, ct);
await keycloak.Groups.RemoveMemberByNameAsync(userId, "developers", ct);

Roles

keycloak.Roles.*

// Realm roles
var roles = await keycloak.Roles.GetAllRealmAsync(ct);
var role = await keycloak.Roles.GetRealmByNameAsync("admin", ct);   // Keycloak.RoleNotFound on 404
await keycloak.Roles.CreateRealmAsync(new CreateRoleRequest { Name = "admin" }, ct);
await keycloak.Roles.UpdateRealmAsync("admin", new UpdateRoleRequest { Description = "Administrators" }, ct);
await keycloak.Roles.DeleteRealmAsync("old-role", ct);

// Client roles
var roles = await keycloak.Roles.GetAllClientAsync(clientUuid, ct);
var role = await keycloak.Roles.GetClientByNameAsync(clientUuid, "editor", ct);
await keycloak.Roles.CreateClientAsync(clientUuid, new CreateRoleRequest { Name = "editor" }, ct);
await keycloak.Roles.UpdateClientAsync(clientUuid, "editor", new UpdateRoleRequest { Description = "Editors" }, ct);
await keycloak.Roles.DeleteClientAsync(clientUuid, "old-client-role", ct);

// User realm roles (cross-resource — also on keycloak.Users)
var assigned = await keycloak.Roles.GetUserRealmAsync(userId, ct);
var effective = await keycloak.Roles.GetUserEffectiveRealmAsync(userId, ct);
var available = await keycloak.Roles.GetUserAvailableRealmAsync(userId, ct);
await keycloak.Roles.AssignRealmToUserAsync(userId, [adminRole], ct);
await keycloak.Roles.RemoveRealmFromUserAsync(userId, [adminRole], ct);

// User client roles (cross-resource — also on keycloak.Users)
var assigned = await keycloak.Roles.GetUserClientAsync(userId, clientUuid, ct);
var effective = await keycloak.Roles.GetUserEffectiveClientAsync(userId, clientUuid, ct);
var available = await keycloak.Roles.GetUserAvailableClientAsync(userId, clientUuid, ct);
await keycloak.Roles.AssignClientToUserAsync(userId, clientUuid, [editorRole], ct);
await keycloak.Roles.RemoveClientFromUserAsync(userId, clientUuid, [editorRole], ct);

Clients

keycloak.Clients.*

var clients = await keycloak.Clients.GetAllAsync(ct);
var client = await keycloak.Clients.GetByClientIdAsync("my-app", ct);   // Keycloak.ClientNotFound on a miss
if (await keycloak.Clients.CreateAsync(new CreateClientRequest
{
    ClientId = "my-app",
    PublicClient = true,
    RedirectUris = ["https://app.example.com/*"]
}, ct) is not string clientUuid) return;
await keycloak.Clients.UpdateAsync(clientUuid, new UpdateClientRequest
{
    Name = "My App",
    WebOrigins = ["+"]
}, ct);
await keycloak.Clients.DeleteAsync(clientUuid, ct);

// Client secret
var secret = await keycloak.Clients.GetSecretAsync(clientUuid, ct);
var newSecret = await keycloak.Clients.RegenerateSecretAsync(clientUuid, ct);

// Service account user (Keycloak.UserNotFound when the client has none)
var serviceUser = await keycloak.Clients.GetServiceAccountUserAsync(clientUuid, ct);

Sessions

keycloak.Sessions.*

var sessions = await keycloak.Sessions.GetUserSessionsAsync(userId, ct);
await keycloak.Sessions.LogoutUserAsync(userId, ct);   // all sessions
await keycloak.Sessions.DeleteAsync(sessionId, ct);     // specific session

Authentication

keycloak.Authentication.*

// Login (password grant)
Outcome<KeycloakToken> login = await keycloak.Authentication.LoginAsync(new LoginRequest
{
    Username = "john.doe",
    Password = "password"
}, ct);

if (login is not KeycloakToken token)
{
    // A ProblemDetails, e.g. 401 Keycloak.invalid_grant with Detail "invalid_grant: Invalid user credentials".
    return login.ToHttpResult();
}

// Refresh token
var newToken = await keycloak.Authentication.RefreshTokenAsync(token.RefreshToken!, ct);

// Logout
await keycloak.Authentication.LogoutAsync(token.RefreshToken!, ct);

// UserInfo (requires an access token)
var userInfo = await keycloak.Authentication.GetUserInfoAsync(token.AccessToken, ct);

// Token introspection
if (await keycloak.Authentication.IntrospectTokenAsync(token.AccessToken, ct) is TokenIntrospectionResponse { Active: false })
{
    // Token is expired or revoked
}

The KeycloakToken response includes AccessToken, RefreshToken, ExpiresIn, RefreshExpiresIn, TokenType, and Scope.

Security note: The password grant (grant_type=password) is deprecated by OAuth 2.1. For browser-based applications, use Authorization Code + PKCE instead. The password grant is appropriate for trusted backend services and migration scenarios.


Impersonation

keycloak.Impersonation.*

var result = await keycloak.Impersonation.ImpersonateUserAsync(userId, ct);
// result.SameRealm — whether impersonation is within the same realm
// result.Redirect  — redirect URL for the impersonated session

Note: Requires the impersonation realm role and the impersonation feature enabled on the realm.


Federated Identities

keycloak.FederatedIdentities.*

var identities = await keycloak.FederatedIdentities.GetForUserAsync(userId, ct);

await keycloak.FederatedIdentities.LinkAsync(userId, "google", new FederatedIdentity
{
    IdentityProvider = "google",
    UserId = "google-user-id",
    UserName = "john@gmail.com"
}, ct);

await keycloak.FederatedIdentities.UnlinkAsync(userId, "google", ct);

Token Management

The library manages admin API tokens automatically:

  • Admin token — acquired via the client credentials flow and held in a private singleton (the host's IMemoryCache, and any SizeLimit on it, is not used). Refreshed 30 s before expiry (half the lifetime for very short-lived tokens).
  • Stampede-safe — concurrent requests on a cold cache share one token fetch.
  • Revocation-aware — a 401 with a cached token evicts it (only if it is still the current one, so a burst of concurrent 401s triggers a single refresh) and retries the request once.
  • Failure is an outcome — if the token endpoint rejects the service credentials or returns an unusable token, the admin call is not sent and returns a 503 problem Keycloak.AdminTokenUnavailable. A transport failure reaching the token endpoint still throws HttpRequestException.

You never need to manage admin tokens manually. User tokens (from Authentication.LoginAsync) are returned directly — cache them in your application if needed.


Common Gotchas

Client must have service-accounts-enabled

The admin token handler uses the client credentials flow. Your Keycloak client must have:

  • Client authentication: ON
  • Service accounts roles: ON (enables service-accounts-enabled)
  • The service account must have the realm-management → manage-users role (and other manage-* roles for groups, roles, clients, etc.)

Without this, the token fetch fails and every admin call returns Keycloak.AdminTokenUnavailable (503).

ClientSecret is optional

Public clients (e.g. admin-cli in development) don't have a secret. Set ClientSecret = null (the default). However, production admin API usage should always use a confidential client with a secret.

Trailing slash in KeycloakBaseUrl

The library normalizes trailing slashes automatically. Both https://keycloak.example.com and https://keycloak.example.com/ work correctly.

HTTP Resilience

All Keycloak HttpClient instances use the Microsoft.Extensions.Http.Resilience standard handler (retries with backoff, circuit breaker, timeouts). Retries are disabled for unsafe methods (POST/PUT/PATCH/DELETE): replaying a create after a timeout could create duplicates or surface as a spurious 409 Conflict. Only GET/HEAD/OPTIONS are retried.

Observability

HTTP calls are traced/measured by System.Net.Http. Admin-token acquisition adds a span and two counters (keycloak.client.admin_token.acquisitions tagged outcome, keycloak.client.admin_token.rejections):

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t.AddSource(KeycloakClientDiagnostics.ActivitySourceName))
    .WithMetrics(m => m.AddMeter(KeycloakClientDiagnostics.MeterName));

How It Works

┌──────────────────────────────────────────────────────┐
│  AddKeycloakClient(options)                          │
└─────────────────────┬────────────────────────────────┘
                      │
       ┌──────────────┼──────────────┐
       ▼              ▼              ▼
┌────────────┐ ┌────────────┐ ┌────────────────────┐
│ Admin      │ │ Token      │ │ Sub-Clients        │
│ HttpClient │ │ HttpClient │ │                    │
├────────────┤ ├────────────┤ ├────────────────────┤
│ BaseUrl +  │ │ BaseUrl    │ │ Users, Groups,     │
│ /admin/    │ │ (token     │ │ Roles, Clients,    │
│ realms/    │ │ endpoint)  │ │ Sessions, Auth,    │
│ {realm}/   │ │            │ │ Impersonation,     │
│            │ │            │ │ FederatedIdentities│
│ + Admin    │ │ + Standard │ │                    │
│   Token    │ │   Resilience│ │ → IKeycloakClient │
│   Handler  │ │   Handler  │ │   (facade)        │
│ + Standard │ │            │ │                    │
│   Resilience│ │            │ │                    │
└────────────┘ └────────────┘ └────────────────────┘
  1. Options validation — IValidateOptions<KeycloakClientOptions> validates required fields at startup. Fails fast with OptionsValidationException.
  2. HttpClient registration — Named HttpClient instances: realm admin (/admin/realms/{realm}/), root admin (/admin/, for Realms), both with the auto-token DelegatingHandler, and token (for user auth flows). All have standard resilience handlers with retries limited to safe methods.
  3. Token handler — AttachAdminAccessTokenHandler attaches the admin token from a singleton token provider (client credentials, stampede-safe, refreshed before expiry, evicted on 401).
  4. Facade — IKeycloakClient is the single injection point. Each sub-client (Users, Groups, etc.) owns its own slice of the Keycloak Admin REST API.

Requirements

  • .NET 11 (net11.0); <LangVersion>latest</LangVersion> to pattern-match Outcome<T> (C# 15 unions)
  • LowCodeHub.Outcomes (pulled in automatically) and the ASP.NET Core shared framework
  • A Keycloak server (tested with Keycloak 26+)

License

MIT

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 63 10/22/2026
0.0.11 72 10/22/2026
0.0.10 235 6/21/2026
0.0.3 120 5/18/2026
0.0.2 138 4/23/2026
0.0.1 121 5/12/2026