litestar-keycloak-admin
Account and user management for Litestar applications that use Keycloak as their identity provider. It lets your frontend keep its own login form, lets users manage their own account, and lets administrators create users with any realm roles. Neither the browser nor your code ever has to talk to Keycloak directly.
The project is currently alpha software. Pin the version, test upgrades in a staging environment, and review the security notes before using it in production.
The package is built on top of litestar-keycloak and complements it rather than replacing it:
| Concern | Handled by |
|---|---|
Validating access tokens (JWKS, iss, aud, exp) |
litestar-keycloak |
current_user injection, require_roles / require_scopes guards |
litestar-keycloak |
| Login with username and password, refresh, logout | this package |
| Replacing a temporary password at first login | this package |
| Own profile and password | this package |
| Listing realm roles, registering users | this package |
| Creating users from the command line (e.g. the first admin) | this package |
KeycloakAdminPlugin installs litestar-keycloak's KeycloakPlugin for you, so an application
registers exactly one plugin.
Contents: Installation · Quick start · Keycloak setup · Protecting routes · Login flow · Endpoints · Errors · Configuration · Command line · Using the client · Public API · Security notes
Installation
Python 3.12 or newer is required.
# pip
python -m pip install litestar-keycloak-admin
# or uv
uv add litestar-keycloak-admin
It depends on litestar-keycloak (0.3.x) and aiohttp; both are installed with it.
Quick start
from litestar import Litestar, get
from litestar_keycloak import CurrentUser, MatchStrategy, require_roles
from litestar_keycloak_admin import KeycloakAdminConfig, KeycloakAdminPlugin
@get("/reports", guards=[require_roles("admin", "analyst", strategy=MatchStrategy.ANY)])
async def reports(current_user: CurrentUser) -> dict[str, str]:
return {"requested_by": current_user.preferred_username or current_user.sub}
app = Litestar(
route_handlers=[reports],
plugins=[
KeycloakAdminPlugin(
KeycloakAdminConfig.from_env(keycloak={"exclude_patterns": ("^/schema", "^/health$")}),
)
],
)
With KEYCLOAK_INTERNAL_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET
set, the application now has:
- every route protected by a Keycloak access token, except
/schema,/healthand the public auth endpoints; /reportsopen to users holdingadminoranalyst;- the auth endpoints under
/auth(see Endpoints).
A minimal local environment looks like this:
KEYCLOAK_INTERNAL_URL=http://localhost:8080
KEYCLOAK_REALM=my-realm
KEYCLOAK_CLIENT_ID=my-backend
KEYCLOAK_CLIENT_SECRET=replace-me
# Optional: use a master-realm administrator for local development.
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=replace-me-too
Keep secrets outside source control. In production, prefer a narrowly scoped service account as described below.
Keycloak setup
The package expects:
- A confidential client (Client authentication On) with Direct access grants enabled.
This is the client the backend uses for the password grant. Its ID and secret go into
KEYCLOAK_CLIENT_ID/KEYCLOAK_CLIENT_SECRET. - An audience mapper on that client, so access tokens carry its ID in
aud(Client scopes → dedicated scope → Add mapper → Audience). Without it every token is rejected as having the wrong audience. - Realm roles for your application, e.g.
admin,user,dispatcher. They are not listed anywhere in code: define them in Keycloak (or in the realm export you import) and they can be assigned and checked right away. One realm role,adminby default, is the admin role: its holders may list roles and register users. - Admin REST API access, in one of two ways:
- set
KEYCLOAK_ADMIN/KEYCLOAK_ADMIN_PASSWORDto a master-realm administrator (simplest, fine for development); or - leave them unset and enable Service accounts on the client, then give its service account
the
realm-managementrolesmanage-usersandview-realm(recommended in production: the backend gets only the rights it needs, in one realm).
- set
A minimal realm export with both roles and the client:
{
"realm": "my-realm",
"roles": {
"realm": [
{ "name": "admin", "description": "Full administrative access" },
{ "name": "user", "description": "Regular user" }
]
},
"clients": [
{
"clientId": "my-backend",
"publicClient": false,
"secret": "${KEYCLOAK_CLIENT_SECRET}",
"directAccessGrantsEnabled": true,
"standardFlowEnabled": false,
"protocolMappers": [
{
"name": "audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": { "included.client.audience": "my-backend", "access.token.claim": "true" }
}
]
}
]
}
Protecting routes
Authentication is on by default: litestar-keycloak rejects any request without a valid
token with 401. Make a route public in one of two ways:
- a path pattern in
exclude_patterns(regular expressions, matched against the path):KeycloakAdminConfig.from_env(keycloak={"exclude_patterns": ("^/schema", "^/webhooks/")}); - a handler flag:
@get("/health", opt={"exclude_from_auth": True}).
On a protected route, inject the caller as current_user: CurrentUser (the parameter must have
exactly this name). It is a litestar_keycloak.KeycloakUser with sub, preferred_username,
email, given_name, family_name, realm_roles and client_roles.
Restrict a route to certain roles with require_roles:
from litestar_keycloak import MatchStrategy, require_roles
from litestar_keycloak_admin import requires_admin
require_roles("admin", "dispatcher", strategy=MatchStrategy.ANY) # holds at least one of them
require_roles("dispatcher", "supervisor") # holds every one of them
requires_admin # holds the configured admin role
Note:
require_rolesdefaults toMatchStrategy.ALL. For "any of these roles", which is what most role checks mean, passstrategy=MatchStrategy.ANYexplicitly.
A caller lacking the roles gets 403.
Login flow
The endpoints are designed for a browser frontend with its own login form:
- Log in:
POST /auth/loginwith{"username", "password"}. The response holds the access token. The refresh token is set as anhttponlycookie and is never visible to JavaScript. - First login of a new user: users created by an admin have a temporary password, so the
login responds
403with the reasonpasswordChangeRequired(see Errors). The frontend asks for a new password and callsPOST /auth/initial-passwordwith{"username", "password", "new_password"}. On success it returns tokens, just like a login. - Calling the API: send
Authorization: Bearer <token>. - Keeping the session alive: before the access token expires (
expires_in, in seconds), callPOST /auth/refresh, or call it when an API request fails with the reasonexpired. The browser sends the cookie on its own; no body is needed. A401from/refreshitself means the Keycloak session is over and the user has to log in again. - Logging out:
POST /auth/logoutends the Keycloak session and clears the cookie. Dropping the access token in the browser alone would leave the session usable until it expires.
For non-browser clients (mobile apps, scripts), disable the cookie with
refresh_cookie=RefreshCookieConfig(enabled=False): the refresh token is then returned in the
response body and sent back as {"refresh_token": "..."} to /refresh and /logout.
Endpoints
All paths are relative to auth_path (default /auth).
Public (no token)
| Method and path | Body | Success |
|---|---|---|
POST /login |
username, password |
200, TokenResponse |
POST /initial-password |
username, password (the temporary one), new_password |
200, TokenResponse |
POST /refresh |
refresh_token (only without the cookie) |
200, TokenResponse |
POST /logout |
refresh_token (only without the cookie) |
204 |
TokenResponse is
{"token": str, "expires_in": int, "refresh_expires_in": int, "refresh_token": str | null};
refresh_token is null while the cookie is enabled.
Any authenticated user
| Method and path | Body | Success |
|---|---|---|
GET /me |
200, UserResponse |
|
PATCH /me |
any of first_name, last_name, email ("" removes the e-mail) |
200, UserResponse |
POST /password |
current_password, new_password |
204 |
UserResponse is
{"id", "username", "email", "first_name", "last_name", "roles": [str]}. It is built from the
access token, so after PATCH /me the token itself keeps the old values until it is refreshed.
Admin role only
| Method and path | Body | Success |
|---|---|---|
GET /roles |
200, [{"name", "description"}] |
|
POST /register |
username, password, roles: [str], optional email, first_name, last_name |
201, UserResponse |
GET /roles returns the realm's own roles and leaves out Keycloak's built-in ones
(offline_access, uma_authorization, default-roles-<realm>). Use it to fill a role picker.
POST /register creates the user with a temporary password and the given roles, at least
one. It fails with 400, and creates nothing, if a role doesn't exist in the realm or isn't in
assignable_roles.
Errors
Every error, whether it comes from this package, from litestar-keycloak (tokens, roles) or from
Litestar itself (validation), has the same shape:
{
"error": {
"errors": [
{
"domain": "global",
"reason": "invalidParameter",
"message": "Password must be at least 8 characters long",
"locationType": "body",
"location": "password"
}
],
"code": 400,
"message": "Password must be at least 8 characters long"
}
}
coderepeats the HTTP status, andmessageis meant for humans.errors[].reasonis the stable, machine-readable part: branch on it, not on the message.locationandlocationTypesay what the error is about, when that is known: a body field (body), a query or path parameter (query,path), or a header (header, e.g.Authorization).
| Status | reason |
When |
|---|---|---|
400 |
invalidParameter |
A field is missing, has the wrong type or fails a check (password too short, no roles); one item per field |
400 |
invalid |
Keycloak refused the input: role missing from the realm, password breaks the realm's policy |
400 |
badRequest |
Malformed request, e.g. invalid JSON |
401 |
required |
No access token, or no session to refresh |
401 |
expired |
The access token has expired: refresh it |
401 |
authError |
Invalid token, wrong credentials, expired or ended session |
403 |
passwordChangeRequired |
Login with a temporary password: call /initial-password |
403 |
insufficientPermissions |
The caller lacks a required role |
403 |
insufficientScope |
The token lacks a required scope |
409 |
conflict |
User or e-mail already exists; /initial-password for an account without a temporary password |
500 |
internalError |
Unexpected server error; no details are exposed |
502 |
backendError |
Keycloak answered with something unexpected |
503 |
backendError |
Keycloak is unreachable |
Token errors also set WWW-Authenticate: Bearer.
The plugin applies this format to its own endpoints and to token and role errors. To use it for the whole application, register the same handlers app-wide:
from litestar import Litestar
from litestar.exceptions import HTTPException
from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR
from litestar_keycloak_admin import handle_http_exception, handle_internal_error
app = Litestar(
exception_handlers={
HTTPException: handle_http_exception,
HTTP_500_INTERNAL_SERVER_ERROR: handle_internal_error,
},
...,
)
In your own handlers, HTTPException(..., extra={"reason": "..."}) sets the reason, and
error_response(status, message, [ErrorItem(...)]) builds a response directly.
Configuration
KeycloakAdminConfig.from_env() reads these variables (prefix KEYCLOAK_, change it with
from_env(prefix=...)):
| Variable | Required | Meaning |
|---|---|---|
INTERNAL_URL |
yes | Where the backend reaches Keycloak, e.g. http://keycloak:8080 |
REALM |
yes | Realm name |
CLIENT_ID |
yes | The confidential client (see Keycloak setup) |
CLIENT_SECRET |
yes | Its secret |
ISSUER |
no | Expected iss: the URL users reach Keycloak at, plus /realms/<realm>. Needed when it differs from INTERNAL_URL, e.g. behind a reverse proxy |
AUDIENCE |
no | Expected aud; defaults to CLIENT_ID |
ADMIN, ADMIN_PASSWORD |
no | Master-realm administrator for the Admin REST API; without them the client's service account is used |
REFRESH_COOKIE_PATH |
no | Cookie path when a reverse proxy serves the API under a prefix, e.g. /api/auth; otherwise the browser never sends the cookie back |
Keyword arguments refine the result:
keycloak={...}passes extra arguments tolitestar_keycloak.KeycloakConfig, e.g.exclude_patterns,jwks_cache_ttl,optional_audiences;- other arguments override
KeycloakAdminConfigfields:
| Field | Default | Meaning |
|---|---|---|
admin_role |
"admin" |
Realm role allowed to list roles and register users |
assignable_roles |
() |
Roles registration may assign; empty means every realm role |
auth_path |
"/auth" |
Where the endpoints are mounted |
refresh_cookie |
RefreshCookieConfig() |
enabled, name (kc_refresh), path, secure, samesite (strict) |
min_password_length |
8 |
Checked before Keycloak's own password policy |
timeout |
10 |
Seconds per request to Keycloak |
KeycloakAdminConfig(keycloak=KeycloakConfig(...), ...) can also be built directly, without
environment variables.
Command line
The plugin adds a keycloak group to the litestar CLI. Its main use is creating the first
administrator, when nobody can log in to /register yet:
KEYCLOAK_NEW_USER_PASSWORD='temporary-secret' \
litestar keycloak create-user root --role admin --exist-ok
--rolemay be repeated to assign several roles;- the password comes from
KEYCLOAK_NEW_USER_PASSWORDor an interactive prompt, never from command-line arguments, which other processes can see; - the password is temporary: the user replaces it at first login;
--exist-okexits successfully if the user already exists, so the command is safe to run on every deployment.
Using the client in your own code
KeycloakAdminClient is available to any handler as the keycloak_admin dependency, for
user-management needs beyond the built-in endpoints. For example, an administrator resetting a
user's password to a temporary one, which the user replaces at the next login:
from dataclasses import dataclass
from litestar import post
from litestar.di import NamedDependency
from litestar.exceptions import NotFoundException
from litestar.params import FromPath
from litestar_keycloak_admin import KeycloakAdminClient, requires_admin
@dataclass
class ResetPasswordRequest:
password: str
@post("/users/{username:str}/reset-password", guards=[requires_admin], status_code=204)
async def reset_password(
username: FromPath[str],
data: ResetPasswordRequest,
keycloak_admin: NamedDependency[KeycloakAdminClient],
) -> None:
subject = await keycloak_admin.find_user_id(username)
if subject is None:
raise NotFoundException(f"User {username!r} not found")
await keycloak_admin.set_password(subject, data.password, temporary=True)
| Method | Does |
|---|---|
login(username, password) |
Password grant; returns Keycloak's token response |
refresh(refresh_token) |
New tokens for a live session |
logout(refresh_token) |
Ends the session |
check_password(username, password) |
Whether the password is the user's current one |
complete_initial_password(username, password, new_password) |
Replaces a temporary password and logs in |
list_roles() |
The realm's own roles, without Keycloak's built-in ones |
create_user(username=..., password=..., roles=..., ...) |
Creates a user, returns its subject id |
update_user(subject, **representation) |
Partial update with Keycloak's UserRepresentation field names |
set_password(subject, password, temporary=False) |
Sets a password |
find_user_id(username) |
Subject id for an exact username, or None |
Its errors (KeycloakClientError and subclasses) are turned into the HTTP responses listed in
Errors wherever they are raised.
Public API
Everything below is importable from litestar_keycloak_admin; other modules are internal.
| Name | Kind |
|---|---|
KeycloakAdminPlugin |
The plugin to register on the application |
KeycloakAdminConfig, RefreshCookieConfig |
Configuration |
KeycloakAdminClient |
Asynchronous client for Keycloak's token endpoints and Admin REST API |
requires_admin |
Guard: the caller holds admin_role |
KeycloakSessionController, KeycloakAccountController, UserResponse |
The built-in endpoints and their user model |
handle_http_exception, handle_internal_error, error_response, ErrorItem |
The error format |
KeycloakClientError, KeycloakUnavailableError, KeycloakLoginError, PasswordChangeRequiredError, KeycloakAdminError |
Client exceptions |
The package follows Semantic Versioning; while it is in 0.x, a minor
release may contain breaking changes, which are listed in the
changelog.
Security notes
- Password grant. The login endpoints use OAuth's Resource Owner Password Credentials
grant: the password passes through your backend to Keycloak and is never stored. It is what
allows a custom login form, but OAuth 2.1 deprecates it, and it rules out Keycloak features
that need its own login page (social login, WebAuthn, its two-factor flows). If you need them,
use
litestar-keycloak's own routes (Authorization Code flow) instead of the public endpoints here. - Refresh token. By default it stays in an
httponly,secure,SameSite=Strictcookie scoped toauth_path, out of reach of JavaScript.securecookies need HTTPS; disable it only for local development. - Admin credentials. Prefer a service account with
manage-usersandview-realmto a master-realm administrator in production.
Development
uv sync --group dev
uv run ruff check .
uv run ruff format --check .
uv run pytest
uv build
uv run twine check --strict dist/*
The tests start a fake Keycloak on aiohttp.web (tests/fake_keycloak.py), so they run real
HTTP against both this package and litestar-keycloak without a Keycloak instance.
See CONTRIBUTING.md for contribution guidelines, CHANGELOG.md for user-visible changes, and the release guide for the release process.
License
Distributed under the MIT License.
Metadata
Release files for litestar-keycloak-admin 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| litestar_keycloak_admin-0.1.0.tar.gz | 22.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| litestar_keycloak_admin-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 47.6 kB
Release files / litestar_keycloak_admin-0.1.0.tar.gz
| Download URL | litestar_keycloak_admin-0.1.0.tar.gz |
|---|---|
| Size | 22.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8339daba68b13a70540a694d6ef29fa229434444d67b71991c115abd75868815
|
|
BLAKE2b-256 checksum How to use checksums |
dffe6f570460ac7065c2e92dd53c6e1357ef24d11c75763de9dd7aa78c228d2f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / litestar_keycloak_admin-0.1.0-py3-none-any.whl
| Download URL | litestar_keycloak_admin-0.1.0-py3-none-any.whl |
|---|---|
| Size | 25.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
65579e218f0e0fc3569bb2565330998ed96ae400916bb48321f26ff0a9bd67a8
|
|
BLAKE2b-256 checksum How to use checksums |
27fadae1176cf1c0126759e397372df3c7d9c53d807aa7721100ea662100e08d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency log