Skip to main content

django-pyoidc-keycloak-extensions

Keycloak user, group and role synchronisation, encrypted token storage and RFC 8693 token exchange for Django projects that authenticate through django-pyoidc.

django-pyoidc handles the OIDC login flow and stops there. This library adds what a project needs when Keycloak is the system of record:

  • Identity is the Keycloak UUID, not an email address. Emails change and get reused.
  • Users stay in step with Keycloak — renames, disables and deletions arrive through event polling, with full reconciliation as the correctness backstop.
  • Deleted accounts are removed, or anonymised when local data still references them.
  • Groups and roles mirror Keycloak, with temporary manual overrides an admin can grant. Realm roles and the OIDC client's roles are stored locally; is_staff and is_superuser are derived from client roles.
  • Login applies the ID token immediately, unless a reconcile has produced newer data.
  • Permissions are never stored locally. Every has_perm goes to your own authorization backend (Open Policy Agent, or whatever you use).
  • Raw tokens are stored encrypted, refreshed lazily, and exchangeable for another audience.

Requirements

  • Python 3.14+, Django 5.2+, django-pyoidc 1.0.13+
  • Keycloak 26.2+ for token exchange
  • A Redis server as Django's default cache. The token-refresh lock needs it (see Using the tokens), and no other cache backend will do. django-redis and redis-py are installed as dependencies; you run the Redis server yourself.
  • Celery 5.4+ (optional), installed through the celery extra. Without it, sync runs from cron and the admin's bulk actions run inline (see Scheduling).

Installation

uv pip install django-pyoidc-keycloak-extensions            # core, needs Redis
uv pip install "django-pyoidc-keycloak-extensions[celery]"  # also installs Celery tasks

Keycloak setup

This library reuses the client you already configured for django-pyoidc — there is no separate service account to create. On that client:

  1. Client authentication: on (it must be confidential).

  2. Service accounts roles: on.

  3. On the service-account user, assign these realm-management roles: view-users, query-users, query-groups, view-events, view-realm, view-clients.

  4. In Realm settings → Sessions/Events, enable admin events and user events — event polling reads both, and neither is on by default.

  5. For token exchange: switch on Standard token exchange on the client, and make sure the user holds a role on the target client so that audience is within your client's scope.

  6. Create the client roles app-staff and app-superuser on the client (or change STAFF_ROLES / SUPERUSER_ROLES), plus whatever roles your policy reads.

  7. Add three protocol mappers to the client so login can read membership and roles from the ID token without an Admin API call. Keycloak's default roles scope only puts roles in the access token; these copy them into the ID token and userinfo as well:

    Mapper type Claim Setting
    Group Membership groups Full group path: on
    User Client Role resource_access.${client_id}.roles Multivalued: on
    User Realm Role realm_access.roles Multivalued: on

    Enable Add to ID token, Add to access token and Add to userinfo on each. The exact JSON is in tests/integration/realm-export.json.

This grants the browser-facing login client read access to the realm's user directory. A leaked client secret therefore exposes more than it would with a separate admin client — an accepted trade-off for a single set of credentials. Split them with KEYCLOAK["ADMIN_CLIENT_ID"] / ["ADMIN_CLIENT_SECRET"] if you would rather not.

Django setup

AUTH_USER_MODEL must be set before the project's first migrate. Changing it later means a manual migration.

INSTALLED_APPS = [
    ...,
    "django_pyoidc",
    "django_pyoidc_keycloak",
]

AUTH_USER_MODEL = "keycloak.KeycloakUser"

# The first backend resolves the logged-in user from the session on every request; the
# second decides every permission. ModelBackend must NOT be here: a system check rejects
# it, because it would answer has_perm() from the database.
AUTHENTICATION_BACKENDS = [
    "django_pyoidc_keycloak.backends.KeycloakSessionBackend",
    "myproject.authz.OPABackend",
]

SALT_KEY = env("SALT_KEY")  # token encryption; see "Token encryption" below

# Must be django-redis: token refresh locks through it (system check keycloak.E008).
# Use a trusted, access-controlled Redis; see "Security notes".
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": env("REDIS_URL"),  # e.g. "redis://redis:6379/0"
    }
}

DJANGO_PYOIDC = {
    "sso": {
        "client_id": "django-app",
        "client_secret": env("OIDC_CLIENT_SECRET"),
        "provider_class": "KeycloakProvider",
        "keycloak_base_uri": "https://sso.example.org",
        "keycloak_realm": "myrealm",
        "hook_get_user": "django_pyoidc_keycloak.hooks.get_user",
        "hook_user_login": "django_pyoidc_keycloak.hooks.user_login",
        "hook_user_logout": "django_pyoidc_keycloak.hooks.user_logout",
        "hook_session_logout": "django_pyoidc_keycloak.hooks.session_logout",
    }
}

KEYCLOAK = {
    "OP_NAME": "sso",
}

Your authorization backend

Permissions only -- nothing about users:

class OPABackend:
    def has_perm(self, user_obj, perm, obj=None): ...
    def has_module_perms(self, user_obj, app_label): ...
    def get_all_permissions(self, user_obj, obj=None):  # optional; the admin index uses it
        ...

Every configured backend is asked in turn, so your backend does not have to be the one the session records. In particular it needs no get_user: Django resolves the logged-in user by calling get_user() on the backend stored in the session, and KeycloakSessionBackend -- shipped with this library -- is what serves that request. It does the primary-key lookup and nothing else, so a deactivated or anonymised account stops resolving to a session immediately, and no permission ever comes out of the database.

is_superuser short-circuits to True before your backend is consulted, and is_staff gates admin access.

KeycloakSessionBackend must appear in AUTHENTICATION_BACKENDS (system check keycloak.E004): django.contrib.auth ignores a session backend that is not listed there and silently falls back to AnonymousUser on every request. You may subclass it; the library discovers it by type.

Permissions your policy will be asked about

All of the form <app_label>.<verb>_<model_name>, so a swapped model changes both halves:

Verb Example Meaning
view / add / change / delete keycloak.change_keycloakuser Django's four, unchanged.
sync keycloak.sync_keycloakuser Pull this record from Keycloak now.

sync is this library's own verb, and it is independent of change in both directions. It gates the "Sync now" button on the user page and the two bulk actions on the changelist. Synchronising is neither reading nor editing: it pulls the record from the realm and, when the account has gone, deletes or anonymises it locally. So a policy can grant sync without change -- an operator who may repair drift but not hand-edit fields -- or change without sync, for someone who administers local-only accounts but must not trigger Admin API traffic. Without the verb the button is not rendered and the actions do not appear in the changelist dropdown.

No Permission row is created for sync (none is created for anything -- see CREATE_DJANGO_PERMISSIONS); the string is simply what your backend is asked about.

Logging

Every module logs under its own name below django_pyoidc_keycloak, so the whole library can be turned up at once:

LOGGING = {
    "version": 1,
    "loggers": {
        "django_pyoidc_keycloak": {"level": "DEBUG", "handlers": ["console"]},
    },
}
Level What you get
INFO Synchronisation runs and their counts, users deleted or anonymised, event cursors advancing, token sets dropped or purged.
DEBUG Every decision behind those: the poll window, why an event was skipped, which fields a representation changed, each Admin API request and status, refresh-lock contention.
WARNING Something was survivable but did not happen -- a role read that failed, tokens that could not be stored.

Nothing logged is a secret or personal data, at any level. Tokens, client secrets and passwords never reach a log record: HTTP response bodies are passed through scrub() before they are rendered, and request payloads are never logged at all. Accounts are identified by keycloak_id and local primary key, never by username, email or name; a Keycloak representation is logged as its list of keys, and a change as its list of field names. Group paths and role names are logged, since they are realm configuration rather than user data.

tests/test_logging.py enforces this with sentinel values, so it stays true.

Scheduling

*/2 *  * * *  manage.py keycloak_sync_events    # incremental
17  *  * * *  manage.py keycloak_reconcile      # the correctness backstop
30  3  * * *  manage.py keycloak_purge_tokens   # expired tokens, memberships and role assignments

Both are needed. Admin events only cover changes made through the Admin API or console; self-service edits land in the user event stream, LDAP-federated changes produce no events at all, and events expire. keycloak_reconcile is what guarantees convergence.

With Celery installed, django_pyoidc_keycloak.tasks offers the same operations as tasks, and the admin's bulk actions enqueue instead of blocking the request.

Token encryption

Tokens are stored in Fernet-encrypted columns via django-fernet-encrypted-fields, which derives its key from SECRET_KEY and SALT_KEY. Set SALT_KEY to a long random value kept out of version control. Rotating SECRET_KEY without listing the old value in SECRET_KEY_FALLBACKS makes existing tokens unreadable — they are session-scoped and disposable, so the library logs a warning and treats them as absent rather than erroring.

Tokens are at rest in exactly one place: those columns. Never the cache, the session, a log line, or an admin page.

Using the tokens

from django_pyoidc_keycloak.tokens.refresh import get_access_token_for_user
from django_pyoidc_keycloak.tokens.exchange import exchange_token

token = get_access_token_for_user(request.user)          # refreshed if near expiry
downstream = exchange_token(request.user, audience="reports-api")

Refresh is lazy and on demand, never scheduled: refreshing on a timer resets Keycloak's SSO Session Idle clock (defeating idle timeout), is still capped by SSO Session Max, and races the user's own browser refresh, which trips reuse detection when rotation is on. For work while the user is away, set KEYCLOAK["REQUEST_OFFLINE_ACCESS"] = True to obtain an offline token, which is exempt from SSO Session Max.

Concurrent refreshes are serialised by a distributed lock served through django-redis — redis-py's Lock, acquired with SET NX PX and released by a token-checked Lua script, so a worker whose lock expired can never release the next worker's. This is why django-redis is a mandatory dependency and CACHES["default"] must point at django_redis.cache.RedisCache; a system check (keycloak.E008) enforces it at start-up.

Groups and roles

Keycloak's group tree, its realm roles and the roles of your OIDC client are mirrored into KeycloakGroup and KeycloakRole, and a user's membership and assignments into the GroupMembership and RoleAssignment through models. Both through models carry a source: keycloak rows are added and removed by synchronisation to match the realm exactly, manual rows are an admin override that survives synchronisation until expires_at passes.

request.user.active_groups()                       # unexpired memberships
request.user.active_roles()                        # unexpired assignments
request.user.has_role("feature1-editor", "django-app")
request.user.has_role("app-admin")                 # client_id="" is a realm role

Roles are stored effective: composites are expanded, both when read from the Admin API and in tokens, so the two paths agree. Other clients' roles are ignored unless you list them in KEYCLOAK["ROLE_CLIENTS"]; Keycloak's built-in realm-management, account and broker roles never reach the local table by default.

is_staff and is_superuser are derived from role references in STAFF_ROLES and SUPERUSER_ROLES. A bare name is a role on the OIDC client, realm:name a realm role, and other-client:name a role on a client that is also in ROLE_CLIENTS. The defaults are the client roles app-staff and app-superuser. An empty list leaves that flag for you to manage by hand.

Login versus reconcile

At login the groups, realm_access and resource_access claims are applied immediately, so a user sees a change in Keycloak on their next sign-in without waiting for a sync. But a token is only as fresh as its iat, and a reconcile or event poll may have run since. The user row records authorization_synced_at: Admin API synchronisation stamps it with the read time, a login stamps it with the token's iat, and a login whose token predates the stamp leaves groups, roles and the staff flags alone. A claim that is missing altogether falls back to the Admin API for that kind of data; a present-but-empty claim means "no roles".

Your own model base

Every domain model (user, group, membership, role, assignment) inherits from the abstract model named by KEYCLOAK_MODEL_BASE, which supplies the UUID primary key and created_at / updated_at. Point it at your own abstract model to add soft deletion, history or a manager to all of them at once. If your base adds fields, you must also subclass the abstract models in your own app, point AUTH_USER_MODEL and the KEYCLOAK_*_MODEL settings at them and own their migrations; system check keycloak.E009 refuses to start otherwise, because the library's migrations know nothing about your columns.

Settings

Setting Default Meaning
OP_NAME auto Which DJANGO_PYOIDC provider to use; required if several are configured.
SERVER_URL / REALM from django-pyoidc Override the derived Keycloak location.
ADMIN_CLIENT_ID / ADMIN_CLIENT_SECRET from django-pyoidc Use a separate admin client.
IMPORT_ALL_USERS False Create local users for accounts that never logged in.
SYNC_ON_LOGIN True Refresh the user from claims at each login.
SYNC_GROUPS True Mirror group membership.
SYNC_ROLES True Mirror realm and client roles and their assignments.
ROLE_CLIENTS the OIDC client Which clients' roles are mirrored, by clientId. Realm roles always are.
USERNAME_STRATEGY built-in Dotted path to your own username derivation.
STAFF_ROLES / SUPERUSER_ROLES ["app-staff"] / ["app-superuser"] Role references that map to is_staff / is_superuser: a bare name is a client role on the OIDC client, realm:name a realm role. Changed in 0.3: realm roles now need the realm: prefix.
CREATE_DJANGO_PERMISSIONS False Let Django populate auth_permission again.
STORE_TOKENS True Store raw tokens at login.
REQUEST_OFFLINE_ACCESS False Request offline_access scope.
TOKEN_EXCHANGE_ENABLED False Enables token exchange; the call refuses while off.
ADMIN_BULK_INLINE_LIMIT 50 Cap on synchronising inline from the admin without Celery.
EVENT_OVERLAP_SECONDS 300 How far back each event poll re-reads.

Top-level Django settings, not under KEYCLOAK:

Setting Default Meaning
KEYCLOAK_GROUP_MODEL / KEYCLOAK_MEMBERSHIP_MODEL keycloak.KeycloakGroup / keycloak.GroupMembership Swap in your own concrete models.
KEYCLOAK_ROLE_MODEL / KEYCLOAK_ROLE_ASSIGNMENT_MODEL keycloak.KeycloakRole / keycloak.RoleAssignment Likewise for roles.
KEYCLOAK_MODEL_BASE django_pyoidc_keycloak.models.base.KeycloakModelBase Abstract base of every domain model (see above).

Security notes

The Django cache must be trusted storage. django-pyoidc stores its pyoidc state in the cache and reads it back with jsonpickle.decode (upstream marks this noqa: S301), so anyone who can write to that cache can execute code in your process on the next decode. Do not point CACHES["default"] at a Redis or memcached instance shared with less-trusted components, and keep it authenticated and network-isolated.

Group and role mappers must derive from actual membership and role mappings. At login, membership and roles are read from the groups, realm_access and resource_access claims when present, and is_staff / is_superuser follow. This library only ever grants groups and roles Keycloak owns — a locally created group or role can never be reached through a claim — but if you configure a mapper over a user-editable attribute, a user controls their own claim content, and with it the staff flags.

Token encryption uses PBKDF2-SHA256 at 100 000 iterations, which is what django-fernet-encrypted-fields does and is below OWASP's current 600k guidance. Session tokens are short-lived, so this is minor; weigh it if you enable REQUEST_OFFLINE_ACCESS, since offline tokens live much longer.

django-pyoidc has no upper version bound. It holds the actual OIDC request paths, so pin it in your own project and read its release notes before upgrading.

Notes on behaviour worth knowing

  • Local-only users are never touched. A user with keycloak_id = NULL (your bootstrap superuser, for instance) survives every reconciliation.
  • Anonymisation keeps a tombstone. keycloak_id is retained so a deleted Keycloak account is never re-imported as a fresh user.
  • Service-account users are not imported. Keycloak excludes them from GET /users, so reconciliation never sees them; one is only created if it actually logs in.
  • Inactive users have no permissions. has_perm returns False for is_active=False before any backend is consulted, so disabling an account in Keycloak revokes access as soon as sync notices, without waiting for your policy engine.
  • Two empty tables remain. django.contrib.auth cannot be removed from INSTALLED_APPS, so auth_permission and auth_group exist. This library never reads or writes them, and permission creation is disconnected so they stay empty.

Development

uv sync --extra dev --extra celery
uv run pytest                  # unit tests
uv run pytest -m integration   # against a real Keycloak in Podman

The integration suite starts quay.io/keycloak/keycloak:26.4 through Podman's socket, imports a realm, and drives the full cycle: reconcile, mutate, poll, delete, refresh, exchange. It skips itself if Podman is not installed.

The unit suite starts its own throwaway redis:7-alpine container through the same Podman socket and points the cache at it. You do not need a Redis running locally. Without Podman it falls back to a local-memory cache and skips the tests marked redis.

Releasing

Publishing runs on GitHub Actions (.github/workflows/publish.yml) using PyPI Trusted Publishing, so there is no API token in repository secrets.

One-time setup:

  1. On PyPI, add a pending publisher under the project's Publishing settings — owner phi1010, repository django-pyoidc-keycloak-extensions, workflow publish.yml, environment pypi. Repeat on TestPyPI with environment testpypi if you want a dry run.
  2. In the repository settings, create the pypi (and optionally testpypi) environments. Adding required reviewers there puts a manual gate between the release and the upload.

To release:

  1. Bump version in pyproject.toml and commit.
  2. Tag and push: git tag v0.2.0 && git push --tags.
  3. Publish a GitHub Release for that tag.

The workflow re-runs lint, the unit tests and the migration check, refuses to publish if the tag and pyproject.toml disagree on the version, verifies the wheel actually contains the admin templates and migrations, uploads to PyPI, and attaches the artefacts to the release.

workflow_dispatch publishes to TestPyPI by default, for rehearsing a release without burning a version number — PyPI uploads are immutable and a version can never be reused.

Metadata

Release files for django-pyoidc-keycloak-extensions 0.3.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 django-pyoidc-keycloak-extensions 0.3.0
File Size Uploaded
django_pyoidc_keycloak_extensions-0.3.0.tar.gz 120.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-pyoidc-keycloak-extensions 0.3.0
File Interpreter ABI Platform
django_pyoidc_keycloak_extensions-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 210.4 kB

Release files / django_pyoidc_keycloak_extensions-0.3.0.tar.gz

Download URL django_pyoidc_keycloak_extensions-0.3.0.tar.gz
Size 120.5 kB
Tags Source
SHA-256 checksum
How to use checksums
da351f20d5b45f2bd9157055f6d53e63fa2f76c116e8adac2dddfba1667c8a2c
BLAKE2b-256 checksum
How to use checksums
f2eda9b316a4eedcfc6af9cee582b4ad4f6d7ce2a0f0bf68069022c9a028b68d
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 Sep 19, 2026.

Transparency log

Release files / django_pyoidc_keycloak_extensions-0.3.0-py3-none-any.whl

Download URL django_pyoidc_keycloak_extensions-0.3.0-py3-none-any.whl
Size 90.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3de4e97369a6571f152a8ca6be042b25868978045f30125704bbab2dd5e7112a
BLAKE2b-256 checksum
How to use checksums
3fbcfa3a859479c154ef255c8c0ece72aba4f2b7ee7f371d4639640dc92997ac
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 Sep 19, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.2

2 release files

0.3.1

2 release files

This release

0.3.0 This release

2 release files

0.2.4

2 release files

0.2.2

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