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
| Field | Value |
|---|---|
name | Keycloak |
route_prefix | /api/keycloak |
view_prefix | /keycloak |
depends_on | ["Auth"] (optional: ["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:
@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 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:
- 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). At access-token expiry, a stored refresh token renews the session server-side with a five-second token-endpoint timeout. The new access token is validated through the JWKS cache, and its claims replace the cached roles and deadline before the request continues. Demoted users lose their old privileges and disabled users lose access at the next access-token expiry. A refused refresh (for exampleinvalid_grant), or an expired session without a refresh token, clears authentication and requires sign-in; a network error or 5xx only signs that request out and keeps the session so the next request retries. Refresh runs at most once per request and never for bearer credentials. Parallel requests that cross the deadline may each refresh: with Keycloak's default (refresh tokens reusable) that is harmless, but a realm with Revoke Refresh Token enabled should set Refresh Token Max Reuse to at least 1, otherwise the losing request getsinvalid_grantand signs the user out. Sessions created before refresh-token renewal shipped sign in once after upgrading. Ordinary requests do not slide the deadline.
- If there's an
- If the provider returns
None, it falls through to the registered principal resolvers (app.state.auth.principal_resolvers), trying each until one matches. - If still unresolved and the path isn't public:
/api/*and bearer requests get a401JSON; browser requests are redirected toprovider.get_login_url(...)(/keycloak/login), with the original URL stashed in the session asnext.
The browser session uses a signed, unencrypted cookie, so the refresh token is Fernet-encrypted (key derived with HKDF from secret_key) before it is stored; a rotated key or a tampered value counts as no refresh token and requires sign-in. To stay below the roughly 4 KB browser limit with a refresh token and role claims, the ID token is validated at sign-in but never stored. Logout first ends the SSO session back-channel (POST to the end-session endpoint with the client credentials and the refresh token; failures are logged and never block the redirect), then redirects with client_id instead of id_token_hint.
KeycloakModule.register_public_routes 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 + path | What |
|---|---|
GET /keycloak/login | Inertia Keycloak/Login page (auto-redirects to the IdP). |
POST /keycloak/logout | Clears the session, redirects to Keycloak's RP-initiated logout with client_id and post_logout_redirect_uri back to /keycloak/logged-out. |
GET /api/keycloak/auth/login | Builds the authorization URL (random state + nonce stored in session) and 302s to Keycloak. |
GET /api/keycloak/auth/callback | Verifies state, exchanges the code for tokens, validates both tokens and checks the ID token nonce and matching subject. Upserts the user cache, replaces the session (preserving registered browser gate keys), writes UserContext, the refresh token (if returned) and access-token expiry, then redirects to the safe 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).
| Field | Env var | Default | Notes |
|---|---|---|---|
server_url | SM_KEYCLOAK_SERVER_URL | "" | Keycloak base URL |
realm | SM_KEYCLOAK_REALM | "" | realm name |
client_id | SM_KEYCLOAK_CLIENT_ID | "" | also the expected JWT audience |
client_secret | SM_KEYCLOAK_CLIENT_SECRET | "" | confidential-client secret |
trust_tenant_claim | SM_KEYCLOAK_TRUST_TENANT_CLAIM | false | see 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 | — | 3600 | signing-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)
| Column | Type | Notes |
|---|---|---|
id | UUID | PK; the stable framework user id |
keycloak_sub | str | unique, indexed; the Keycloak sub claim |
email | str | from the token |
full_name | str | None | from preferred_username |
last_login_at | datetime | None | refreshed on each resolve |
The cache backs this module's user directory (keycloak.directory.KeycloakDirectory, installed in register_settings), so the audit log, dashboard, file storage and the permissions grants screen name and count Keycloak users. It knows only users who have signed in at least once; roles are None (they live in the IdP) and created_this_month is None. permissions (role pages) still needs the users tables and declares depends_on=["Users"], so leave it out of a Keycloak install (SM_MODULES_ENABLED). api_tokens only orders after Users: under Keycloak its service keys work and personal tokens are refused (the owner's roles live in the IdP).
Menu
| Label | URL | Icon | Section | Order | Method |
|---|---|---|---|---|---|
Logout | /keycloak/logout | log-out | USER_DROPDOWN | 999 | post |
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.
usersandkeycloak). 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:
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.