Skip to content

keycloak ​

A pluggable OIDC auth provider backed by Keycloak. Install it instead of users to delegate identity — login, users, and roles — to Keycloak. Browser users are redirected to Keycloak's hosted login; API/mobile clients send a Bearer access token that the module validates against Keycloak's JWKS.

ModuleMeta ​

FieldValue
nameKeycloak
route_prefix/api/keycloak
view_prefix/keycloak
depends_on["Auth", "Settings"]

The module sets the class flag _is_auth_provider = True, which is what the auth-provider diagnostics count (see below).

The AuthProvider contract ​

keycloak implements the auth module's AuthProvider protocol (auth.contracts.provider.AuthProvider). The contract is:

python
@runtime_checkable
class AuthProvider(Protocol):
    name: str

    async def resolve_user(self, request: Request) -> UserContext | None: ...
    def get_login_url(self, request: Request, next_url: str | None = None) -> str: ...
    def get_logout_url(self, request: Request) -> str: ...
    def get_public_paths(self) -> tuple[tuple[str, ...], tuple[str, ...]]: ...
    def is_bearer_request(self, request: Request) -> bool: ...

KeycloakAuthProvider (name = "keycloak") registers itself on app.state.auth.auth_provider during register_settings. Exactly one module — users or keycloak, not both — may do this.

Slotting into the principal-resolver chain ​

The provider-agnostic AuthMiddleware (in auth) runs on every request and:

  1. Calls provider.resolve_user(request). The Keycloak provider:
    • If there's an Authorization: Bearer <token> header, validates the JWT against the cached JWKS and maps claims → UserContext.
    • Otherwise rehydrates UserContext.from_session_dict(...) from the browser session cookie (populated by the OIDC callback).
  2. If the provider returns None, it falls through to the registered principal resolvers (app.state.auth.principal_resolvers), trying each until one matches.
  3. If still unresolved and the path isn't public: /api/* and bearer requests get a 401 JSON; browser requests are redirected to provider.get_login_url(...) (/keycloak/login), with the original URL stashed in the session as next.

get_public_paths() exempts /keycloak/login, /keycloak/logout, and /api/keycloak/auth/ from auth.

Bearer-token validation ​

JWKSCache (keycloak/jwks.py) caches Keycloak's RS256 public signing keys (/realms/<realm>/protocol/openid-connect/certs) and validates tokens with PyJWT, checking signature, issuer, and audience (the client_id). It refetches once on a cache miss / unknown kid, so Keycloak key rotation is handled gracefully. The cache is built in on_startup (only when server_url and realm are configured) and attached to the provider.

On a valid bearer token the provider upserts a KeycloakUserCache row keyed by the Keycloak sub, so each external identity maps to a stable framework UUID, then returns a UserContext whose id is that UUID.

Browser login flow (OIDC) ​

OIDCClient (keycloak/oidc.py) wraps the realm's authorization-code endpoints.

Method + pathWhat
GET /keycloak/loginInertia Keycloak/Login page (auto-redirects to the IdP).
POST /keycloak/logoutClears the session, redirects to Keycloak's RP-initiated logout (post_logout_redirect_uri back to /keycloak/login, with id_token_hint).
GET /api/keycloak/auth/loginBuilds the authorization URL (random state + nonce stored in session) and 302s to Keycloak.
GET /api/keycloak/auth/callbackVerifies state, exchanges the code for tokens, validates the access token, upserts the user cache, writes UserContext + the id token to the session, then redirects to next (or login_redirect_url).

Settings ​

DB-backed via register_module_settings("keycloak", KeycloakSettings, ...); bootstrap defaults come from SM_KEYCLOAK_* env vars. State lives at app.state.keycloak (KeycloakState: settings, jwks_cache).

FieldEnv varDefaultNotes
server_urlSM_KEYCLOAK_SERVER_URL""Keycloak base URL
realmSM_KEYCLOAK_REALM""realm name
client_idSM_KEYCLOAK_CLIENT_ID""also the expected JWT audience
client_secretSM_KEYCLOAK_CLIENT_SECRET""confidential-client secret
trust_tenant_claimSM_KEYCLOAK_TRUST_TENANT_CLAIMfalsesee Tenant claim
roles_claim_path—"realm_access.roles"dotted path to the roles array in the token
admin_role—"admin"
login_redirect_url—"/dashboard/"post-login landing
jwks_cache_ttl_seconds—3600signing-key cache TTL
role_mapping—{"admin": "admin", "user": "user"}Keycloak realm role → framework permission/role

server_url, realm, client_id, and client_secret are required in production — KeycloakSettings raises at boot if any is missing outside a non-prod environment. They may be left blank in development and filled in later through the settings admin UI.

Tenant claim ​

By default the token's tenant_id claim is ignored: UserContext.tenant_id stays None and, with multi_tenant on and no tenant resolver, tenant-scoped queries fail closed. The claim is only as trustworthy as the realm mapper behind it: a mapper that reads a user-editable attribute lets a user pick their tenant.

Set trust_tenant_claim (SM_KEYCLOAK_TRUST_TENANT_CLAIM=true) only when the realm alone controls the claim (a hardcoded or admin-only protocol mapper) and the tenants module is not installed. When tenants is installed its resolver decides the active tenant from tenants_membership on every request and the claim never wins, whether or not this setting is on, so realms may stop issuing it.

The setting is read on every request, not only at sign-in: turning it off drops the tenant from sessions that already exist, without anyone signing out. A trusted claim must also be a well-formed tenant id (letters, digits, _ . : -, at most 50 characters); anything else is ignored and logged.

Keycloak registers no setup steps: its local users table is legitimately empty, so a first-run wizard keyed on a superuser count would lock the install out. A Keycloak-only install creates its first organisation from /tenants after the first login.

Role mapping ​

role_mapping translates Keycloak realm roles (read from roles_claim_path) into the framework's role names. Roles the user has that aren't in the mapping are dropped, so only explicitly mapped roles reach the UserContext. A mapping onto a tenant role (tenant:owner, tenant:admin, tenant:member) is ignored, with a warning logged once per role: those roles come only from the tenants module, for the active tenant.

Models ​

KeycloakUserCache (table keycloak_user_cache)

ColumnTypeNotes
idUUIDPK; the stable framework user id
keycloak_substrunique, indexed; the Keycloak sub claim
emailstrfrom the token
full_namestr | Nonefrom preferred_username
last_login_atdatetime | Nonerefreshed on each resolve
LabelURLIconSectionOrderMethod
Logout/keycloak/logoutlog-outUSER_DROPDOWN999post

Inertia pages ​

  • Keycloak/Login.tsx — the "redirecting to identity provider" splash.
  • Keycloak/LoggedOut.tsx — post-logout confirmation.

Locales ​

Top-level keys in keycloak/locales/en.json (namespace keycloak): login, logout, errors.

Auth-provider diagnostics ​

The framework's module diagnostics (run by make doctor and at prod boot) count modules with _is_auth_provider = True:

  • SM020 (error) — more than one auth provider installed (e.g. users and keycloak). Install only one.
  • SM021 (warning) — no auth provider installed at all.

In production these errors fail boot. So swapping to Keycloak means removing simple_module_users from the host's dependencies, not running it alongside.

Enabling ​

Add the package to the host instead of simple_module_users:

toml
dependencies = [
    "simple_module_keycloak==0.0.17",
]

Then uv sync --all-packages. The entry point (keycloak = "keycloak.module:KeycloakModule") is discovered automatically; set the four SM_KEYCLOAK_* env vars (or configure them via the settings admin UI after first boot). It depends on the auth and settings modules.

Released under the MIT License.