Skip to main content

litestar-keycloak-admin

PyPI Python CI License: MIT

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, /health and the public auth endpoints;
  • /reports open to users holding admin or analyst;
  • 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:

  1. 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.
  2. 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.
  3. 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, admin by default, is the admin role: its holders may list roles and register users.
  4. Admin REST API access, in one of two ways:
    • set KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD to 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-management roles manage-users and view-realm (recommended in production: the backend gets only the rights it needs, in one realm).

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_roles defaults to MatchStrategy.ALL. For "any of these roles", which is what most role checks mean, pass strategy=MatchStrategy.ANY explicitly.

A caller lacking the roles gets 403.

Login flow

The endpoints are designed for a browser frontend with its own login form:

  1. Log in: POST /auth/login with {"username", "password"}. The response holds the access token. The refresh token is set as an httponly cookie and is never visible to JavaScript.
  2. First login of a new user: users created by an admin have a temporary password, so the login responds 403 with the reason passwordChangeRequired (see Errors). The frontend asks for a new password and calls POST /auth/initial-password with {"username", "password", "new_password"}. On success it returns tokens, just like a login.
  3. Calling the API: send Authorization: Bearer <token>.
  4. Keeping the session alive: before the access token expires (expires_in, in seconds), call POST /auth/refresh, or call it when an API request fails with the reason expired. The browser sends the cookie on its own; no body is needed. A 401 from /refresh itself means the Keycloak session is over and the user has to log in again.
  5. Logging out: POST /auth/logout ends 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.

Login verification by code

Set login_verification to require a one-time code after the password. The application decides how the code reaches the user - the package only calls send_code:

from litestar_keycloak_admin import KeycloakAdminConfig, LoginCode, LoginVerificationConfig


async def send_code(code: LoginCode) -> None:
    await mailer.send(code.email, f"Your login code: {code.code}")  # or publish to a queue


config = KeycloakAdminConfig.from_env(
    login_verification=LoginVerificationConfig(send_code=send_code),
)

POST /login and POST /initial-password then check the password and answer 202 {"challenge_id", "expires_in", "destination": "a***@example.com", "code_required": true} instead of tokens. POST /login/verify with the right code returns the usual TokenResponse (and the refresh cookie); POST /login/resend sends a new code for the same challenge.

  • The Keycloak tokens of the checked password wait in a Litestar store under LoginVerificationConfig.store and are refreshed on success. Register a shared store (e.g. RedisStore) under that name when the app runs several workers.
  • The code is kept only as an HMAC; max_attempts wrong codes (default 5) end the Keycloak session. Codes live ttl_seconds (300); resend_interval_seconds (60) and max_sends (4) limit resending.
  • The e-mail comes from the access token's email claim; an account without one gets 403 emailRequired. A send_code that raises gives 503 codeDeliveryFailed.
  • Error reasons: invalidCode (401), challengeExpired (401), tooManyAttempts (429), tooManySends (429), resendTooSoon (429 with Retry-After).

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
POST /login/verify challenge_id, code 200, TokenResponse
POST /login/resend challenge_id 202, LoginChallengeResponse

The last two exist only with login_verification (see below); otherwise they answer 404.

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"
  }
}
  • code repeats the HTTP status, and message is meant for humans.
  • errors[].reason is the stable, machine-readable part: branch on it, not on the message.
  • location and locationType say 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 to litestar_keycloak.KeycloakConfig, e.g. exclude_patterns, jwks_cache_ttl, optional_audiences;
  • other arguments override KeycloakAdminConfig fields:
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
  • --role may be repeated to assign several roles;
  • the password comes from KEYCLOAK_NEW_USER_PASSWORD or 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-ok exits 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=Strict cookie scoped to auth_path, out of reach of JavaScript. secure cookies need HTTPS; disable it only for local development.
  • Admin credentials. Prefer a service account with manage-users and view-realm to 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.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for litestar-keycloak-admin 0.2.0
File Size Uploaded
litestar_keycloak_admin-0.2.0.tar.gz 26.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for litestar-keycloak-admin 0.2.0
File Interpreter ABI Platform
litestar_keycloak_admin-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 57.7 kB

Release files / litestar_keycloak_admin-0.2.0.tar.gz

Download URL litestar_keycloak_admin-0.2.0.tar.gz
Size 26.5 kB
Tags Source
SHA-256 checksum
How to use checksums
e5f072b1d61eea49f50a7ef48956872b393553401539d341f9528aecd332d4f1
BLAKE2b-256 checksum
How to use checksums
125367d60e6f09c60b02e088b48d2610604860b9b7f957586eb91d673fee4a7c
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 8, 2026.

Transparency log

Release files / litestar_keycloak_admin-0.2.0-py3-none-any.whl

Download URL litestar_keycloak_admin-0.2.0-py3-none-any.whl
Size 31.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
82c746e75735e89202e76926e16adb4f615df3d3f2fe277cb2b3472321b6ad6e
BLAKE2b-256 checksum
How to use checksums
ce0459ef04df0855b22c4160a698adaa3fc075a16393bb40bbc47f2a064171a0
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 8, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.1

2 release files

This release

0.2.0 This release

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page