Skip to main content

django-pyoidc-keycloak-extensions

Keycloak user and group 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 mirror Keycloak, with temporary manual overrides an admin can grant.
  • 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.
  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.

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 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 and memberships

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.

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.
USERNAME_STRATEGY built-in Dotted path to your own username derivation.
STAFF_ROLES / SUPERUSER_ROLES [] Realm roles that map to is_staff / is_superuser.
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.

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 mappers must derive from actual group membership. At login, membership is read from the groups claim when present. This library only ever grants membership in groups Keycloak owns — a locally created group can never be reached through a claim — but if you configure the mapper over a user-editable attribute, a user controls their own claim content.

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_pyoidc_keycloak_extensions-0.2.4.tar.gz (107.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

File details

Details for the file django_pyoidc_keycloak_extensions-0.2.4.tar.gz.

File metadata

File hashes

Hashes for django_pyoidc_keycloak_extensions-0.2.4.tar.gz
Algorithm Hash digest
SHA256 52d723d945acf36ad750700011be8660d452ff23d5b46aea402902901a03d53c
MD5 468bdd8ff0be108f3b8e3f5ec86c68aa
BLAKE2b-256 ef39cd24ccae5a9bac45359463214d1e9d1d31415bda2c7dc062910fc603a2c4

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_pyoidc_keycloak_extensions-0.2.4.tar.gz:

Publisher: publish.yml on phi1010/django-pyoidc-keycloak-extensions

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_pyoidc_keycloak_extensions-0.2.4-py3-none-any.whl.

File metadata

File hashes

Hashes for django_pyoidc_keycloak_extensions-0.2.4-py3-none-any.whl
Algorithm Hash digest
SHA256 29e38ef2ad7af91a451a2ea798db342a76af7f9fcc2edd3a4bf7f5c6bf482aeb
MD5 ca636d160647967e4cb020c1a8a427a2
BLAKE2b-256 3f52ecb2d17c635cad3516868f68d1ce09551da3b50165cc0ee2c434fd2a83b7

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_pyoidc_keycloak_extensions-0.2.4-py3-none-any.whl:

Publisher: publish.yml on phi1010/django-pyoidc-keycloak-extensions

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.4 This release

2 files

0.2.2

2 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