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
<PackageReference Include="LowCodeHub.Keycloak.Client" Version="0.2.0" />
<PackageVersion Include="LowCodeHub.Keycloak.Client" Version="0.2.0" />
<PackageReference Include="LowCodeHub.Keycloak.Client" />
paket add LowCodeHub.Keycloak.Client --version 0.2.0
#r "nuget: LowCodeHub.Keycloak.Client, 0.2.0"
#:package LowCodeHub.Keycloak.Client@0.2.0
#addin nuget:?package=LowCodeHub.Keycloak.Client&version=0.2.0
#tool nuget:?package=LowCodeHub.Keycloak.Client&version=0.2.0
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.
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
- Errors and input validation
- Realms
- Users
- Groups
- Roles
- Clients
- Sessions
- Authentication
- Impersonation
- Federated Identities
- Token Management
- Common Gotchas
- How It Works
- Requirements
- License
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
Detailis Keycloak'serrorMessage, elseerror: 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
errorvalues (letters, digits,_,-,.; up to 64 characters) becomeKeycloak.{error}; free-text values such as"User not found"fall back toKeycloak.{status}and are kept inDetail. - The status is always Keycloak's own, so
ToHttpResult()forwards it unchanged. UseToHttpResult(onSuccess)or aswitchwhen 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..throwArgumentExceptionbefore any request is sent, soUsers.DeleteAsync("..")can never resolve toDELETE /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
impersonationrealm 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 anySizeLimiton 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
401with 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 throwsHttpRequestException.
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-usersrole (and othermanage-*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│ │ │ │ │
└────────────┘ └────────────┘ └────────────────────┘
- Options validation —
IValidateOptions<KeycloakClientOptions>validates required fields at startup. Fails fast withOptionsValidationException. - HttpClient registration — Named
HttpClientinstances: realm admin (/admin/realms/{realm}/), root admin (/admin/, forRealms), both with the auto-tokenDelegatingHandler, and token (for user auth flows). All have standard resilience handlers with retries limited to safe methods. - Token handler —
AttachAdminAccessTokenHandlerattaches the admin token from a singleton token provider (client credentials, stampede-safe, refreshed before expiry, evicted on 401). - Facade —
IKeycloakClientis 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-matchOutcome<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 | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net11.0 is compatible. |
-
net11.0
- LowCodeHub.Outcomes (>= 0.0.1)
- Microsoft.Extensions.Http.Resilience (>= 10.11.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.