django-pyoidc-keycloak-extensions 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_pyoidc_keycloak_extensions-0.2.2/.gitignore +164 -0
- django_pyoidc_keycloak_extensions-0.2.2/LICENSE +21 -0
- django_pyoidc_keycloak_extensions-0.2.2/PKG-INFO +342 -0
- django_pyoidc_keycloak_extensions-0.2.2/PLAN.md +628 -0
- django_pyoidc_keycloak_extensions-0.2.2/README.md +301 -0
- django_pyoidc_keycloak_extensions-0.2.2/manage.py +11 -0
- django_pyoidc_keycloak_extensions-0.2.2/pyproject.toml +95 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/__init__.py +5 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin.py +373 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/client.py +349 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/exceptions.py +43 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/admin_api/provider.py +148 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/apps.py +66 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/backends.py +78 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/checks.py +211 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/conf.py +86 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/hooks.py +167 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_purge_tokens.py +23 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_reconcile.py +46 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_sync_events.py +34 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/management/commands/keycloak_sync_user.py +48 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/managers.py +59 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/migrations/0001_initial.py +160 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/migrations/0002_alter_groupmembership_source.py +18 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/migrations/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/__init__.py +30 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/base.py +208 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/concrete.py +33 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/sync.py +93 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/models/tokens.py +87 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/permissions.py +123 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/scrub.py +67 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/signals.py +29 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/events.py +297 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/groups.py +224 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/reconcile.py +160 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/runs.py +70 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/usernames.py +90 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/sync/users.py +286 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tasks.py +77 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/templates/admin/keycloak/keycloakuser/change_form.html +17 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/exchange.py +118 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/extract.py +121 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/fields.py +38 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/refresh.py +216 -0
- django_pyoidc_keycloak_extensions-0.2.2/src/django_pyoidc_keycloak/tokens/store.py +164 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/conftest.py +56 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/integration/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/integration/conftest.py +207 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/integration/realm-export.json +183 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/integration/test_keycloak_integration.py +285 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_admin_client.py +285 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_authorization.py +124 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_checks_and_admin.py +367 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_events.py +211 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_extract_real_pyoidc.py +111 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_groups.py +172 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_hooks.py +175 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_logging.py +291 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_reconcile.py +159 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_secret_handling.py +119 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_security_regressions.py +213 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_session_backend.py +143 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_sync_users.py +214 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_tokens.py +375 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/test_usernames.py +66 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/migrations/0001_initial.py +33 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/migrations/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testapp/models.py +26 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/__init__.py +0 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/backend.py +49 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/settings.py +86 -0
- django_pyoidc_keycloak_extensions-0.2.2/tests/testproject/urls.py +4 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
### Python template
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
#Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# poetry
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
103
|
+
#poetry.lock
|
|
104
|
+
|
|
105
|
+
# pdm
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
107
|
+
#pdm.lock
|
|
108
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
109
|
+
# in version control.
|
|
110
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
111
|
+
.pdm.toml
|
|
112
|
+
.pdm-python
|
|
113
|
+
.pdm-build/
|
|
114
|
+
|
|
115
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
116
|
+
__pypackages__/
|
|
117
|
+
|
|
118
|
+
# Celery stuff
|
|
119
|
+
celerybeat-schedule
|
|
120
|
+
celerybeat.pid
|
|
121
|
+
|
|
122
|
+
# SageMath parsed files
|
|
123
|
+
*.sage.py
|
|
124
|
+
|
|
125
|
+
# Environments
|
|
126
|
+
.env
|
|
127
|
+
.venv
|
|
128
|
+
env/
|
|
129
|
+
venv/
|
|
130
|
+
ENV/
|
|
131
|
+
env.bak/
|
|
132
|
+
venv.bak/
|
|
133
|
+
|
|
134
|
+
# Spyder project settings
|
|
135
|
+
.spyderproject
|
|
136
|
+
.spyproject
|
|
137
|
+
|
|
138
|
+
# Rope project settings
|
|
139
|
+
.ropeproject
|
|
140
|
+
|
|
141
|
+
# mkdocs documentation
|
|
142
|
+
/site
|
|
143
|
+
|
|
144
|
+
# mypy
|
|
145
|
+
.mypy_cache/
|
|
146
|
+
.dmypy.json
|
|
147
|
+
dmypy.json
|
|
148
|
+
|
|
149
|
+
# Pyre type checker
|
|
150
|
+
.pyre/
|
|
151
|
+
|
|
152
|
+
# pytype static type analyzer
|
|
153
|
+
.pytype/
|
|
154
|
+
|
|
155
|
+
# Cython debug symbols
|
|
156
|
+
cython_debug/
|
|
157
|
+
|
|
158
|
+
# PyCharm
|
|
159
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
160
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
161
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
162
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
163
|
+
#.idea/
|
|
164
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Phillip Kuhrt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-pyoidc-keycloak-extensions
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Keycloak user/group synchronisation, encrypted token storage and token exchange for django-pyoidc
|
|
5
|
+
Project-URL: Homepage, https://github.com/phi1010/django-pyoidc-keycloak-extensions
|
|
6
|
+
Project-URL: Source, https://github.com/phi1010/django-pyoidc-keycloak-extensions
|
|
7
|
+
Project-URL: Issues, https://github.com/phi1010/django-pyoidc-keycloak-extensions/issues
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: django,keycloak,oidc,sso,token-exchange
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Web Environment
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Framework :: Django :: 6.0
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.14
|
|
24
|
+
Requires-Dist: django-fernet-encrypted-fields>=0.4.0
|
|
25
|
+
Requires-Dist: django-pyoidc>=1.0.13
|
|
26
|
+
Requires-Dist: django>=5.2
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: urllib3>=2
|
|
29
|
+
Provides-Extra: celery
|
|
30
|
+
Requires-Dist: celery>=5.4; extra == 'celery'
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: django-stubs>=5.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest-django>=4.8; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
37
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
39
|
+
Requires-Dist: testcontainers>=4.8; extra == 'dev'
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
|
|
42
|
+
# django-pyoidc-keycloak-extensions
|
|
43
|
+
|
|
44
|
+
Keycloak user and group synchronisation, encrypted token storage and RFC 8693 token exchange
|
|
45
|
+
for Django projects that authenticate through
|
|
46
|
+
[django-pyoidc](https://pypi.org/project/django-pyoidc/).
|
|
47
|
+
|
|
48
|
+
django-pyoidc handles the OIDC login flow and stops there. This library adds what a project
|
|
49
|
+
needs when Keycloak is the system of record:
|
|
50
|
+
|
|
51
|
+
* **Identity is the Keycloak UUID**, not an email address. Emails change and get reused.
|
|
52
|
+
* **Users stay in step with Keycloak** — renames, disables and deletions arrive through
|
|
53
|
+
event polling, with full reconciliation as the correctness backstop.
|
|
54
|
+
* **Deleted accounts are removed, or anonymised** when local data still references them.
|
|
55
|
+
* **Groups mirror Keycloak**, with temporary manual overrides an admin can grant.
|
|
56
|
+
* **Permissions are never stored locally.** Every `has_perm` goes to your own authorization
|
|
57
|
+
backend (Open Policy Agent, or whatever you use).
|
|
58
|
+
* **Raw tokens are stored encrypted**, refreshed lazily, and exchangeable for another audience.
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
Python 3.14+, Django 5.2+, django-pyoidc 1.0.13+, Keycloak 26.2+ for token exchange.
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv pip install django-pyoidc-keycloak-extensions
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Keycloak setup
|
|
71
|
+
|
|
72
|
+
This library reuses the client you already configured for django-pyoidc — there is no
|
|
73
|
+
separate service account to create. On that client:
|
|
74
|
+
|
|
75
|
+
1. **Client authentication: on** (it must be confidential).
|
|
76
|
+
2. **Service accounts roles: on.**
|
|
77
|
+
3. On the service-account user, assign these `realm-management` roles:
|
|
78
|
+
`view-users`, `query-users`, `query-groups`, `view-events`, `view-realm`.
|
|
79
|
+
4. In *Realm settings → Sessions/Events*, enable **admin events** and **user events** — event
|
|
80
|
+
polling reads both, and neither is on by default.
|
|
81
|
+
5. For token exchange: switch on **Standard token exchange** on the client, and make sure the
|
|
82
|
+
user holds a role on the target client so that audience is within your client's scope.
|
|
83
|
+
|
|
84
|
+
> This grants the browser-facing login client read access to the realm's user directory. A
|
|
85
|
+
> leaked client secret therefore exposes more than it would with a separate admin client — an
|
|
86
|
+
> accepted trade-off for a single set of credentials. Split them with
|
|
87
|
+
> `KEYCLOAK["ADMIN_CLIENT_ID"]` / `["ADMIN_CLIENT_SECRET"]` if you would rather not.
|
|
88
|
+
|
|
89
|
+
## Django setup
|
|
90
|
+
|
|
91
|
+
`AUTH_USER_MODEL` must be set **before the project's first migrate**. Changing it later means
|
|
92
|
+
a manual migration.
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
INSTALLED_APPS = [
|
|
96
|
+
...,
|
|
97
|
+
"django_pyoidc",
|
|
98
|
+
"django_pyoidc_keycloak",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
AUTH_USER_MODEL = "keycloak.KeycloakUser"
|
|
102
|
+
|
|
103
|
+
# The first backend resolves the logged-in user from the session on every request; the
|
|
104
|
+
# second decides every permission. ModelBackend must NOT be here: a system check rejects
|
|
105
|
+
# it, because it would answer has_perm() from the database.
|
|
106
|
+
AUTHENTICATION_BACKENDS = [
|
|
107
|
+
"django_pyoidc_keycloak.backends.KeycloakSessionBackend",
|
|
108
|
+
"myproject.authz.OPABackend",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
SALT_KEY = env("SALT_KEY") # token encryption; see "Token encryption" below
|
|
112
|
+
|
|
113
|
+
DJANGO_PYOIDC = {
|
|
114
|
+
"sso": {
|
|
115
|
+
"client_id": "django-app",
|
|
116
|
+
"client_secret": env("OIDC_CLIENT_SECRET"),
|
|
117
|
+
"provider_class": "KeycloakProvider",
|
|
118
|
+
"keycloak_base_uri": "https://sso.example.org",
|
|
119
|
+
"keycloak_realm": "myrealm",
|
|
120
|
+
"hook_get_user": "django_pyoidc_keycloak.hooks.get_user",
|
|
121
|
+
"hook_user_login": "django_pyoidc_keycloak.hooks.user_login",
|
|
122
|
+
"hook_user_logout": "django_pyoidc_keycloak.hooks.user_logout",
|
|
123
|
+
"hook_session_logout": "django_pyoidc_keycloak.hooks.session_logout",
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
KEYCLOAK = {
|
|
128
|
+
"OP_NAME": "sso",
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Your authorization backend
|
|
133
|
+
|
|
134
|
+
Permissions only -- nothing about users:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
class OPABackend:
|
|
138
|
+
def has_perm(self, user_obj, perm, obj=None): ...
|
|
139
|
+
def has_module_perms(self, user_obj, app_label): ...
|
|
140
|
+
def get_all_permissions(self, user_obj, obj=None): # optional; the admin index uses it
|
|
141
|
+
...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Every configured backend is asked in turn, so your backend does not have to be the one the
|
|
145
|
+
session records. In particular it needs no `get_user`: Django resolves the logged-in user
|
|
146
|
+
by calling `get_user()` on the backend stored in the session, and
|
|
147
|
+
`KeycloakSessionBackend` -- shipped with this library -- is what serves that request. It
|
|
148
|
+
does the primary-key lookup and nothing else, so a deactivated or anonymised account stops
|
|
149
|
+
resolving to a session immediately, and no permission ever comes out of the database.
|
|
150
|
+
|
|
151
|
+
`is_superuser` short-circuits to `True` before your backend is consulted, and `is_staff`
|
|
152
|
+
gates admin access.
|
|
153
|
+
|
|
154
|
+
`KeycloakSessionBackend` must appear in `AUTHENTICATION_BACKENDS` (system check
|
|
155
|
+
`keycloak.E004`): `django.contrib.auth` ignores a session backend that is not listed there
|
|
156
|
+
and silently falls back to `AnonymousUser` on every request. You may subclass it; the
|
|
157
|
+
library discovers it by type.
|
|
158
|
+
|
|
159
|
+
### Permissions your policy will be asked about
|
|
160
|
+
|
|
161
|
+
All of the form `<app_label>.<verb>_<model_name>`, so a swapped model changes both halves:
|
|
162
|
+
|
|
163
|
+
| Verb | Example | Meaning |
|
|
164
|
+
| --- | --- | --- |
|
|
165
|
+
| `view` / `add` / `change` / `delete` | `keycloak.change_keycloakuser` | Django's four, unchanged. |
|
|
166
|
+
| `sync` | `keycloak.sync_keycloakuser` | Pull this record from Keycloak now. |
|
|
167
|
+
|
|
168
|
+
`sync` is this library's own verb, and it is **independent of `change`** in both directions.
|
|
169
|
+
It gates the "Sync now" button on the user page and the two bulk actions on the changelist.
|
|
170
|
+
Synchronising is neither reading nor editing: it pulls the record from the realm and, when
|
|
171
|
+
the account has gone, deletes or anonymises it locally. So a policy can grant `sync`
|
|
172
|
+
without `change` -- an operator who may repair drift but not hand-edit fields -- or `change`
|
|
173
|
+
without `sync`, for someone who administers local-only accounts but must not trigger Admin
|
|
174
|
+
API traffic. Without the verb the button is not rendered and the actions do not appear in
|
|
175
|
+
the changelist dropdown.
|
|
176
|
+
|
|
177
|
+
No `Permission` row is created for `sync` (none is created for anything -- see
|
|
178
|
+
`CREATE_DJANGO_PERMISSIONS`); the string is simply what your backend is asked about.
|
|
179
|
+
|
|
180
|
+
## Logging
|
|
181
|
+
|
|
182
|
+
Every module logs under its own name below `django_pyoidc_keycloak`, so the whole library
|
|
183
|
+
can be turned up at once:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
LOGGING = {
|
|
187
|
+
"version": 1,
|
|
188
|
+
"loggers": {
|
|
189
|
+
"django_pyoidc_keycloak": {"level": "DEBUG", "handlers": ["console"]},
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
| Level | What you get |
|
|
195
|
+
| --- | --- |
|
|
196
|
+
| `INFO` | Synchronisation runs and their counts, users deleted or anonymised, event cursors advancing, token sets dropped or purged. |
|
|
197
|
+
| `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. |
|
|
198
|
+
| `WARNING` | Something was survivable but did not happen -- a role read that failed, tokens that could not be stored. |
|
|
199
|
+
|
|
200
|
+
**Nothing logged is a secret or personal data, at any level.** Tokens, client secrets and
|
|
201
|
+
passwords never reach a log record: HTTP response bodies are passed through `scrub()` before
|
|
202
|
+
they are rendered, and request payloads are never logged at all. Accounts are identified by
|
|
203
|
+
`keycloak_id` and local primary key, never by username, email or name; a Keycloak
|
|
204
|
+
representation is logged as its list of *keys*, and a change as its list of *field names*.
|
|
205
|
+
Group paths are logged, since they are realm configuration rather than user data.
|
|
206
|
+
|
|
207
|
+
`tests/test_logging.py` enforces this with sentinel values, so it stays true.
|
|
208
|
+
|
|
209
|
+
## Scheduling
|
|
210
|
+
|
|
211
|
+
```cron
|
|
212
|
+
*/2 * * * * manage.py keycloak_sync_events # incremental
|
|
213
|
+
17 * * * * manage.py keycloak_reconcile # the correctness backstop
|
|
214
|
+
30 3 * * * manage.py keycloak_purge_tokens # expired tokens and memberships
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Both are needed. Admin events only cover changes made through the Admin API or console;
|
|
218
|
+
self-service edits land in the user event stream, LDAP-federated changes produce no events at
|
|
219
|
+
all, and events expire. `keycloak_reconcile` is what guarantees convergence.
|
|
220
|
+
|
|
221
|
+
With Celery installed, `django_pyoidc_keycloak.tasks` offers the same operations as tasks, and
|
|
222
|
+
the admin's bulk actions enqueue instead of blocking the request.
|
|
223
|
+
|
|
224
|
+
## Token encryption
|
|
225
|
+
|
|
226
|
+
Tokens are stored in Fernet-encrypted columns via `django-fernet-encrypted-fields`, which
|
|
227
|
+
derives its key from `SECRET_KEY` **and** `SALT_KEY`. Set `SALT_KEY` to a long random value
|
|
228
|
+
kept out of version control. Rotating `SECRET_KEY` without listing the old value in
|
|
229
|
+
`SECRET_KEY_FALLBACKS` makes existing tokens unreadable — they are session-scoped and
|
|
230
|
+
disposable, so the library logs a warning and treats them as absent rather than erroring.
|
|
231
|
+
|
|
232
|
+
Tokens are at rest in exactly one place: those columns. Never the cache, the session, a log
|
|
233
|
+
line, or an admin page.
|
|
234
|
+
|
|
235
|
+
## Using the tokens
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from django_pyoidc_keycloak.tokens.refresh import get_access_token_for_user
|
|
239
|
+
from django_pyoidc_keycloak.tokens.exchange import exchange_token
|
|
240
|
+
|
|
241
|
+
token = get_access_token_for_user(request.user) # refreshed if near expiry
|
|
242
|
+
downstream = exchange_token(request.user, audience="reports-api")
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Refresh is lazy and on demand, never scheduled: refreshing on a timer resets Keycloak's SSO
|
|
246
|
+
Session Idle clock (defeating idle timeout), is still capped by SSO Session Max, and races the
|
|
247
|
+
user's own browser refresh, which trips reuse detection when rotation is on. For work while
|
|
248
|
+
the user is away, set `KEYCLOAK["REQUEST_OFFLINE_ACCESS"] = True` to obtain an offline token,
|
|
249
|
+
which is exempt from SSO Session Max.
|
|
250
|
+
|
|
251
|
+
## Settings
|
|
252
|
+
|
|
253
|
+
| Setting | Default | Meaning |
|
|
254
|
+
| --- | --- | --- |
|
|
255
|
+
| `OP_NAME` | auto | Which `DJANGO_PYOIDC` provider to use; required if several are configured. |
|
|
256
|
+
| `SERVER_URL` / `REALM` | from django-pyoidc | Override the derived Keycloak location. |
|
|
257
|
+
| `ADMIN_CLIENT_ID` / `ADMIN_CLIENT_SECRET` | from django-pyoidc | Use a separate admin client. |
|
|
258
|
+
| `IMPORT_ALL_USERS` | `False` | Create local users for accounts that never logged in. |
|
|
259
|
+
| `SYNC_ON_LOGIN` | `True` | Refresh the user from claims at each login. |
|
|
260
|
+
| `SYNC_GROUPS` | `True` | Mirror group membership. |
|
|
261
|
+
| `USERNAME_STRATEGY` | built-in | Dotted path to your own username derivation. |
|
|
262
|
+
| `STAFF_ROLES` / `SUPERUSER_ROLES` | `[]` | Realm roles that map to `is_staff` / `is_superuser`. |
|
|
263
|
+
| `CREATE_DJANGO_PERMISSIONS` | `False` | Let Django populate `auth_permission` again. |
|
|
264
|
+
| `STORE_TOKENS` | `True` | Store raw tokens at login. |
|
|
265
|
+
| `REQUEST_OFFLINE_ACCESS` | `False` | Request `offline_access` scope. |
|
|
266
|
+
| `TOKEN_EXCHANGE_ENABLED` | `False` | Enables the token-exchange system check. |
|
|
267
|
+
| `ADMIN_BULK_INLINE_LIMIT` | `50` | Cap on synchronising inline from the admin without Celery. |
|
|
268
|
+
| `EVENT_OVERLAP_SECONDS` | `300` | How far back each event poll re-reads. |
|
|
269
|
+
|
|
270
|
+
## Security notes
|
|
271
|
+
|
|
272
|
+
**The Django cache must be trusted storage.** django-pyoidc stores its pyoidc state in the
|
|
273
|
+
cache and reads it back with `jsonpickle.decode` (upstream marks this `noqa: S301`), so
|
|
274
|
+
anyone who can write to that cache can execute code in your process on the next decode. Do
|
|
275
|
+
not point `CACHES["default"]` at a Redis or memcached instance shared with less-trusted
|
|
276
|
+
components, and keep it authenticated and network-isolated.
|
|
277
|
+
|
|
278
|
+
**Group mappers must derive from actual group membership.** At login, membership is read from
|
|
279
|
+
the `groups` claim when present. This library only ever grants membership in groups Keycloak
|
|
280
|
+
owns — a locally created group can never be reached through a claim — but if you configure
|
|
281
|
+
the mapper over a user-editable attribute, a user controls their own claim content.
|
|
282
|
+
|
|
283
|
+
**Token encryption uses PBKDF2-SHA256 at 100 000 iterations**, which is what
|
|
284
|
+
`django-fernet-encrypted-fields` does and is below OWASP's current 600k guidance. Session
|
|
285
|
+
tokens are short-lived, so this is minor; weigh it if you enable `REQUEST_OFFLINE_ACCESS`,
|
|
286
|
+
since offline tokens live much longer.
|
|
287
|
+
|
|
288
|
+
**`django-pyoidc` has no upper version bound.** It holds the actual OIDC request paths, so
|
|
289
|
+
pin it in your own project and read its release notes before upgrading.
|
|
290
|
+
|
|
291
|
+
## Notes on behaviour worth knowing
|
|
292
|
+
|
|
293
|
+
* **Local-only users are never touched.** A user with `keycloak_id = NULL` (your bootstrap
|
|
294
|
+
superuser, for instance) survives every reconciliation.
|
|
295
|
+
* **Anonymisation keeps a tombstone.** `keycloak_id` is retained so a deleted Keycloak account
|
|
296
|
+
is never re-imported as a fresh user.
|
|
297
|
+
* **Service-account users are not imported.** Keycloak excludes them from `GET /users`, so
|
|
298
|
+
reconciliation never sees them; one is only created if it actually logs in.
|
|
299
|
+
* **Inactive users have no permissions.** `has_perm` returns `False` for `is_active=False`
|
|
300
|
+
before any backend is consulted, so disabling an account in Keycloak revokes access as soon
|
|
301
|
+
as sync notices, without waiting for your policy engine.
|
|
302
|
+
* **Two empty tables remain.** `django.contrib.auth` cannot be removed from `INSTALLED_APPS`,
|
|
303
|
+
so `auth_permission` and `auth_group` exist. This library never reads or writes them, and
|
|
304
|
+
permission creation is disconnected so they stay empty.
|
|
305
|
+
|
|
306
|
+
## Development
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
uv sync --extra dev --extra celery
|
|
310
|
+
uv run pytest # unit tests
|
|
311
|
+
uv run pytest -m integration # against a real Keycloak in Podman
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The integration suite starts `quay.io/keycloak/keycloak:26.4` through Podman's socket, imports
|
|
315
|
+
a realm, and drives the full cycle: reconcile, mutate, poll, delete, refresh, exchange. It
|
|
316
|
+
skips itself if Podman is not installed.
|
|
317
|
+
|
|
318
|
+
## Releasing
|
|
319
|
+
|
|
320
|
+
Publishing runs on GitHub Actions (`.github/workflows/publish.yml`) using PyPI **Trusted
|
|
321
|
+
Publishing**, so there is no API token in repository secrets.
|
|
322
|
+
|
|
323
|
+
One-time setup:
|
|
324
|
+
|
|
325
|
+
1. On PyPI, add a *pending publisher* under the project's *Publishing* settings — owner
|
|
326
|
+
`phi1010`, repository `django-pyoidc-keycloak-extensions`, workflow `publish.yml`,
|
|
327
|
+
environment `pypi`. Repeat on TestPyPI with environment `testpypi` if you want a dry run.
|
|
328
|
+
2. In the repository settings, create the `pypi` (and optionally `testpypi`) environments.
|
|
329
|
+
Adding required reviewers there puts a manual gate between the release and the upload.
|
|
330
|
+
|
|
331
|
+
To release:
|
|
332
|
+
|
|
333
|
+
1. Bump `version` in `pyproject.toml` and commit.
|
|
334
|
+
2. Tag and push: `git tag v0.2.0 && git push --tags`.
|
|
335
|
+
3. Publish a GitHub Release for that tag.
|
|
336
|
+
|
|
337
|
+
The workflow re-runs lint, the unit tests and the migration check, refuses to publish if the
|
|
338
|
+
tag and `pyproject.toml` disagree on the version, verifies the wheel actually contains the
|
|
339
|
+
admin templates and migrations, uploads to PyPI, and attaches the artefacts to the release.
|
|
340
|
+
|
|
341
|
+
`workflow_dispatch` publishes to TestPyPI by default, for rehearsing a release without
|
|
342
|
+
burning a version number — PyPI uploads are immutable and a version can never be reused.
|