pyobs-auth 2.0.0.dev0__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.
- pyobs_auth-2.0.0.dev0/.gitignore +168 -0
- pyobs_auth-2.0.0.dev0/LICENSE +22 -0
- pyobs_auth-2.0.0.dev0/PKG-INFO +12 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/__init__.py +11 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/apps.py +6 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/authentication.py +45 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/client.py +119 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/discovery.py +51 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/py.typed +0 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/settings.py +85 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/urls.py +10 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/validation.py +78 -0
- pyobs_auth-2.0.0.dev0/pyobs_auth/views.py +78 -0
- pyobs_auth-2.0.0.dev0/pyproject.toml +54 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# https://github.com/github/gitignore/blob/main/Python.gitignore
|
|
2
|
+
|
|
3
|
+
#Byte-compiled / optimized / DLL files
|
|
4
|
+
__pycache__/
|
|
5
|
+
*.py[cod]
|
|
6
|
+
*$py.class
|
|
7
|
+
|
|
8
|
+
# C extensions
|
|
9
|
+
*.so
|
|
10
|
+
|
|
11
|
+
# Distribution / packaging
|
|
12
|
+
.Python
|
|
13
|
+
build/
|
|
14
|
+
develop-eggs/
|
|
15
|
+
dist/
|
|
16
|
+
downloads/
|
|
17
|
+
eggs/
|
|
18
|
+
.eggs/
|
|
19
|
+
lib/
|
|
20
|
+
lib64/
|
|
21
|
+
parts/
|
|
22
|
+
sdist/
|
|
23
|
+
var/
|
|
24
|
+
wheels/
|
|
25
|
+
share/python-wheels/
|
|
26
|
+
*.egg-info/
|
|
27
|
+
.installed.cfg
|
|
28
|
+
*.egg
|
|
29
|
+
MANIFEST
|
|
30
|
+
|
|
31
|
+
# PyInstaller
|
|
32
|
+
# Usually these files are written by a python script from a template
|
|
33
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
34
|
+
*.manifest
|
|
35
|
+
*.spec
|
|
36
|
+
|
|
37
|
+
# Installer logs
|
|
38
|
+
pip-log.txt
|
|
39
|
+
pip-delete-this-directory.txt
|
|
40
|
+
|
|
41
|
+
# Unit test / coverage reports
|
|
42
|
+
htmlcov/
|
|
43
|
+
.tox/
|
|
44
|
+
.nox/
|
|
45
|
+
.coverage
|
|
46
|
+
.coverage.*
|
|
47
|
+
.cache
|
|
48
|
+
nosetests.xml
|
|
49
|
+
coverage.xml
|
|
50
|
+
*.cover
|
|
51
|
+
*.py,cover
|
|
52
|
+
.hypothesis/
|
|
53
|
+
.pytest_cache/
|
|
54
|
+
cover/
|
|
55
|
+
|
|
56
|
+
# Translations
|
|
57
|
+
*.mo
|
|
58
|
+
*.pot
|
|
59
|
+
|
|
60
|
+
# Django stuff:
|
|
61
|
+
*.log
|
|
62
|
+
local_settings.py
|
|
63
|
+
db.sqlite3
|
|
64
|
+
db.sqlite3-journal
|
|
65
|
+
|
|
66
|
+
# Flask stuff:
|
|
67
|
+
instance/
|
|
68
|
+
.webassets-cache
|
|
69
|
+
|
|
70
|
+
# Scrapy stuff:
|
|
71
|
+
.scrapy
|
|
72
|
+
|
|
73
|
+
# Sphinx documentation
|
|
74
|
+
docs/_build/
|
|
75
|
+
|
|
76
|
+
# PyBuilder
|
|
77
|
+
.pybuilder/
|
|
78
|
+
target/
|
|
79
|
+
|
|
80
|
+
# Jupyter Notebook
|
|
81
|
+
.ipynb_checkpoints
|
|
82
|
+
|
|
83
|
+
# IPython
|
|
84
|
+
profile_default/
|
|
85
|
+
ipython_config.py
|
|
86
|
+
|
|
87
|
+
# pyenv
|
|
88
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
89
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
90
|
+
# .python-version
|
|
91
|
+
|
|
92
|
+
# pipenv
|
|
93
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
94
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
95
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
96
|
+
# install all needed dependencies.
|
|
97
|
+
#Pipfile.lock
|
|
98
|
+
|
|
99
|
+
# poetry
|
|
100
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
101
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
102
|
+
# commonly ignored for libraries.
|
|
103
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
104
|
+
#poetry.lock
|
|
105
|
+
|
|
106
|
+
# pdm
|
|
107
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
108
|
+
#pdm.lock
|
|
109
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
110
|
+
# in version control.
|
|
111
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
112
|
+
.pdm.toml
|
|
113
|
+
|
|
114
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
115
|
+
__pypackages__/
|
|
116
|
+
|
|
117
|
+
# Celery stuff
|
|
118
|
+
celerybeat-schedule
|
|
119
|
+
celerybeat.pid
|
|
120
|
+
|
|
121
|
+
# SageMath parsed files
|
|
122
|
+
*.sage.py
|
|
123
|
+
|
|
124
|
+
# Environments
|
|
125
|
+
.env
|
|
126
|
+
.venv
|
|
127
|
+
env/
|
|
128
|
+
venv/
|
|
129
|
+
ENV/
|
|
130
|
+
env.bak/
|
|
131
|
+
venv.bak/
|
|
132
|
+
|
|
133
|
+
# Spyder project settings
|
|
134
|
+
.spyderproject
|
|
135
|
+
.spyproject
|
|
136
|
+
|
|
137
|
+
# Rope project settings
|
|
138
|
+
.ropeproject
|
|
139
|
+
|
|
140
|
+
# mkdocs documentation
|
|
141
|
+
/site
|
|
142
|
+
|
|
143
|
+
# mypy
|
|
144
|
+
.mypy_cache/
|
|
145
|
+
.dmypy.json
|
|
146
|
+
dmypy.json
|
|
147
|
+
|
|
148
|
+
# Pyre type checker
|
|
149
|
+
.pyre/
|
|
150
|
+
|
|
151
|
+
# pytype static type analyzer
|
|
152
|
+
.pytype/
|
|
153
|
+
|
|
154
|
+
# Cython debug symbols
|
|
155
|
+
cython_debug/
|
|
156
|
+
|
|
157
|
+
# PyCharm
|
|
158
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
159
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
160
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
161
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
162
|
+
.idea/
|
|
163
|
+
|
|
164
|
+
# graphify local build cache and machine-specific state (outputs are tracked)
|
|
165
|
+
graphify-out/cache/
|
|
166
|
+
graphify-out/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/
|
|
167
|
+
graphify-out/.graphify_python
|
|
168
|
+
graphify-out/.graphify_root
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim-Oliver Husser
|
|
4
|
+
thusser@uni-goettingen.de
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pyobs-auth
|
|
3
|
+
Version: 2.0.0.dev0
|
|
4
|
+
Summary: Shared Keycloak/OIDC authentication client for pyobs web services
|
|
5
|
+
Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: django<6,>=5.2
|
|
10
|
+
Requires-Dist: djangorestframework>=3.15
|
|
11
|
+
Requires-Dist: pyjwt[crypto]<3,>=2.10.1
|
|
12
|
+
Requires-Dist: requests<3,>=2.32.3
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .client import KeycloakClient
|
|
2
|
+
from .settings import KeycloakSettings, get_settings
|
|
3
|
+
from .validation import TokenValidationError, TokenValidator
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"KeycloakSettings",
|
|
7
|
+
"get_settings",
|
|
8
|
+
"TokenValidator",
|
|
9
|
+
"TokenValidationError",
|
|
10
|
+
"KeycloakClient",
|
|
11
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""DRF authentication backed by pyobs-auth's JWKS token validation.
|
|
2
|
+
|
|
3
|
+
Single issuer only (the one Keycloak realm in PYOBS_AUTH) - there is deliberately no multi-issuer
|
|
4
|
+
support here. See pyobs-core's shared-auth design doc for why: any upstream identity provider
|
|
5
|
+
(including a self-hosted observation-portal) is meant to be brokered *behind* Keycloak, not
|
|
6
|
+
validated directly by this class, so archive/robotic-backend/etc. only ever need to trust one
|
|
7
|
+
issuer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from rest_framework import authentication, exceptions
|
|
13
|
+
|
|
14
|
+
from .settings import get_settings
|
|
15
|
+
from .validation import TokenValidationError, TokenValidator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KeycloakAuthentication(authentication.BaseAuthentication):
|
|
19
|
+
www_authenticate_realm = "api"
|
|
20
|
+
|
|
21
|
+
def authenticate(self, request):
|
|
22
|
+
auth_header = authentication.get_authorization_header(request).split()
|
|
23
|
+
if not auth_header or auth_header[0].lower() != b"bearer":
|
|
24
|
+
return None
|
|
25
|
+
if len(auth_header) != 2:
|
|
26
|
+
raise exceptions.AuthenticationFailed("Invalid Authorization header")
|
|
27
|
+
|
|
28
|
+
token = auth_header[1].decode()
|
|
29
|
+
settings = get_settings()
|
|
30
|
+
validator = TokenValidator(settings)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
claims = validator.validate(token)
|
|
34
|
+
except TokenValidationError as exc:
|
|
35
|
+
raise exceptions.AuthenticationFailed(str(exc)) from exc
|
|
36
|
+
|
|
37
|
+
user_resolver = settings.resolve_user_callable()
|
|
38
|
+
user = user_resolver(claims)
|
|
39
|
+
if user is None:
|
|
40
|
+
raise exceptions.AuthenticationFailed("No local user for this token")
|
|
41
|
+
|
|
42
|
+
return (user, claims)
|
|
43
|
+
|
|
44
|
+
def authenticate_header(self, request):
|
|
45
|
+
return f'Bearer realm="{self.www_authenticate_realm}"'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""OIDC client: authorization-code + PKCE for user login, client-credentials for
|
|
2
|
+
service-to-service - both against a single Keycloak realm (see settings.py)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import secrets
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from .discovery import fetch_discovery_document
|
|
16
|
+
from .settings import KeycloakSettings
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TokenExchangeError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _b64url(raw: bytes) -> str:
|
|
24
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AuthorizationRequest:
|
|
29
|
+
"""Result of starting a login: send the user to `url`, keep `code_verifier` server-side
|
|
30
|
+
(e.g. in the session) until the callback arrives."""
|
|
31
|
+
|
|
32
|
+
url: str
|
|
33
|
+
state: str
|
|
34
|
+
code_verifier: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class KeycloakClient:
|
|
38
|
+
def __init__(self, settings: KeycloakSettings, *, session: requests.Session | None = None) -> None:
|
|
39
|
+
self._settings = settings
|
|
40
|
+
self._session = session or requests.Session()
|
|
41
|
+
|
|
42
|
+
def _discovery(self):
|
|
43
|
+
return fetch_discovery_document(self._settings.discovery_url)
|
|
44
|
+
|
|
45
|
+
def start_authorization(self, *, redirect_uri: str | None = None) -> AuthorizationRequest:
|
|
46
|
+
"""Build the redirect URL for the authorization-code + PKCE login flow."""
|
|
47
|
+
document = self._discovery()
|
|
48
|
+
state = _b64url(secrets.token_bytes(24))
|
|
49
|
+
code_verifier = _b64url(secrets.token_bytes(48))
|
|
50
|
+
code_challenge = _b64url(hashlib.sha256(code_verifier.encode("ascii")).digest())
|
|
51
|
+
|
|
52
|
+
effective_redirect_uri = redirect_uri or self._settings.redirect_uri
|
|
53
|
+
if not effective_redirect_uri:
|
|
54
|
+
raise ValueError("redirect_uri must be set (either PYOBS_AUTH['REDIRECT_URI'] or the redirect_uri arg)")
|
|
55
|
+
|
|
56
|
+
params = {
|
|
57
|
+
"response_type": "code",
|
|
58
|
+
"client_id": self._settings.client_id,
|
|
59
|
+
"redirect_uri": effective_redirect_uri,
|
|
60
|
+
"scope": " ".join(self._settings.scopes),
|
|
61
|
+
"state": state,
|
|
62
|
+
"code_challenge": code_challenge,
|
|
63
|
+
"code_challenge_method": "S256",
|
|
64
|
+
}
|
|
65
|
+
url = f"{document.authorization_endpoint}?{urlencode(params)}"
|
|
66
|
+
return AuthorizationRequest(url=url, state=state, code_verifier=code_verifier)
|
|
67
|
+
|
|
68
|
+
def exchange_code(self, *, code: str, code_verifier: str, redirect_uri: str | None = None) -> dict[str, Any]:
|
|
69
|
+
"""Swap an authorization code for tokens (access_token, refresh_token, id_token, ...)."""
|
|
70
|
+
document = self._discovery()
|
|
71
|
+
effective_redirect_uri = redirect_uri or self._settings.redirect_uri
|
|
72
|
+
if not effective_redirect_uri:
|
|
73
|
+
raise ValueError("redirect_uri must be set (either PYOBS_AUTH['REDIRECT_URI'] or the redirect_uri arg)")
|
|
74
|
+
|
|
75
|
+
data = {
|
|
76
|
+
"grant_type": "authorization_code",
|
|
77
|
+
"code": code,
|
|
78
|
+
"redirect_uri": effective_redirect_uri,
|
|
79
|
+
"client_id": self._settings.client_id,
|
|
80
|
+
"code_verifier": code_verifier,
|
|
81
|
+
}
|
|
82
|
+
if self._settings.client_secret:
|
|
83
|
+
data["client_secret"] = self._settings.client_secret
|
|
84
|
+
|
|
85
|
+
return self._post_token(document.token_endpoint, data)
|
|
86
|
+
|
|
87
|
+
def client_credentials_token(self, *, scope: str | None = None) -> dict[str, Any]:
|
|
88
|
+
"""Service-to-service token, e.g. for one pyobs web service to call another's API."""
|
|
89
|
+
if not self._settings.client_secret:
|
|
90
|
+
raise ValueError("client_credentials grant requires PYOBS_AUTH['CLIENT_SECRET']")
|
|
91
|
+
|
|
92
|
+
document = self._discovery()
|
|
93
|
+
data = {
|
|
94
|
+
"grant_type": "client_credentials",
|
|
95
|
+
"client_id": self._settings.client_id,
|
|
96
|
+
"client_secret": self._settings.client_secret,
|
|
97
|
+
}
|
|
98
|
+
if scope:
|
|
99
|
+
data["scope"] = scope
|
|
100
|
+
|
|
101
|
+
return self._post_token(document.token_endpoint, data)
|
|
102
|
+
|
|
103
|
+
def refresh(self, *, refresh_token: str) -> dict[str, Any]:
|
|
104
|
+
document = self._discovery()
|
|
105
|
+
data = {
|
|
106
|
+
"grant_type": "refresh_token",
|
|
107
|
+
"refresh_token": refresh_token,
|
|
108
|
+
"client_id": self._settings.client_id,
|
|
109
|
+
}
|
|
110
|
+
if self._settings.client_secret:
|
|
111
|
+
data["client_secret"] = self._settings.client_secret
|
|
112
|
+
|
|
113
|
+
return self._post_token(document.token_endpoint, data)
|
|
114
|
+
|
|
115
|
+
def _post_token(self, token_endpoint: str, data: dict[str, str]) -> dict[str, Any]:
|
|
116
|
+
response = self._session.post(token_endpoint, data=data, timeout=10.0)
|
|
117
|
+
if response.status_code != 200:
|
|
118
|
+
raise TokenExchangeError(f"token endpoint returned {response.status_code}: {response.text}")
|
|
119
|
+
return response.json()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""OIDC discovery document fetch, cached per issuer for the life of the process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class DiscoveryDocument:
|
|
13
|
+
issuer: str
|
|
14
|
+
authorization_endpoint: str
|
|
15
|
+
token_endpoint: str
|
|
16
|
+
userinfo_endpoint: str
|
|
17
|
+
jwks_uri: str
|
|
18
|
+
end_session_endpoint: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_cache: dict[str, DiscoveryDocument] = {}
|
|
22
|
+
_lock = threading.Lock()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fetch_discovery_document(discovery_url: str, *, timeout: float = 10.0) -> DiscoveryDocument:
|
|
26
|
+
with _lock:
|
|
27
|
+
cached = _cache.get(discovery_url)
|
|
28
|
+
if cached is not None:
|
|
29
|
+
return cached
|
|
30
|
+
|
|
31
|
+
response = requests.get(discovery_url, timeout=timeout)
|
|
32
|
+
response.raise_for_status()
|
|
33
|
+
data = response.json()
|
|
34
|
+
|
|
35
|
+
document = DiscoveryDocument(
|
|
36
|
+
issuer=data["issuer"],
|
|
37
|
+
authorization_endpoint=data["authorization_endpoint"],
|
|
38
|
+
token_endpoint=data["token_endpoint"],
|
|
39
|
+
userinfo_endpoint=data["userinfo_endpoint"],
|
|
40
|
+
jwks_uri=data["jwks_uri"],
|
|
41
|
+
end_session_endpoint=data.get("end_session_endpoint"),
|
|
42
|
+
)
|
|
43
|
+
with _lock:
|
|
44
|
+
_cache[discovery_url] = document
|
|
45
|
+
return document
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def clear_discovery_cache() -> None:
|
|
49
|
+
"""Only needed for tests - the cache is otherwise process-lifetime."""
|
|
50
|
+
with _lock:
|
|
51
|
+
_cache.clear()
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Config surface for pyobs-auth: one shared Keycloak realm, one client per service.
|
|
2
|
+
|
|
3
|
+
Read from the Django ``PYOBS_AUTH`` setting, e.g.::
|
|
4
|
+
|
|
5
|
+
PYOBS_AUTH = {
|
|
6
|
+
"SERVER_URL": "https://keycloak.example.org",
|
|
7
|
+
"REALM": "pyobs",
|
|
8
|
+
"CLIENT_ID": "archive",
|
|
9
|
+
"CLIENT_SECRET": os.getenv("KEYCLOAK_CLIENT_SECRET"),
|
|
10
|
+
"REDIRECT_URI": "https://archive.example.org/accounts/keycloak/callback/",
|
|
11
|
+
# dotted path to a callable(claims: dict) -> django.contrib.auth.models.User
|
|
12
|
+
"USER_RESOLVER": "pyobs_archive.authentication.keycloak.resolve_user",
|
|
13
|
+
}
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from importlib import import_module
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class KeycloakSettings:
|
|
26
|
+
server_url: str
|
|
27
|
+
realm: str
|
|
28
|
+
client_id: str
|
|
29
|
+
client_secret: str | None = None
|
|
30
|
+
audience: str | None = None
|
|
31
|
+
redirect_uri: str | None = None
|
|
32
|
+
scopes: tuple[str, ...] = field(default_factory=lambda: ("openid", "profile", "email"))
|
|
33
|
+
user_resolver: str | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def issuer(self) -> str:
|
|
37
|
+
return f"{self.server_url.rstrip('/')}/realms/{self.realm}"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def discovery_url(self) -> str:
|
|
41
|
+
return f"{self.issuer}/.well-known/openid-configuration"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def expected_audience(self) -> str:
|
|
45
|
+
return self.audience or self.client_id
|
|
46
|
+
|
|
47
|
+
def resolve_user_callable(self) -> Callable[[dict[str, Any]], Any]:
|
|
48
|
+
if not self.user_resolver:
|
|
49
|
+
raise ImproperlyConfiguredError(
|
|
50
|
+
"PYOBS_AUTH['USER_RESOLVER'] is not set - pyobs-auth needs a"
|
|
51
|
+
" callable(claims: dict) -> User to map a validated token to a local user"
|
|
52
|
+
)
|
|
53
|
+
module_path, _, attr = self.user_resolver.rpartition(".")
|
|
54
|
+
if not module_path:
|
|
55
|
+
raise ImproperlyConfiguredError(f"PYOBS_AUTH['USER_RESOLVER'] is not a dotted path: {self.user_resolver!r}")
|
|
56
|
+
module = import_module(module_path)
|
|
57
|
+
return getattr(module, attr)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ImproperlyConfiguredError(Exception):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_settings() -> KeycloakSettings:
|
|
65
|
+
"""Build KeycloakSettings from Django's PYOBS_AUTH setting."""
|
|
66
|
+
from django.conf import settings as django_settings
|
|
67
|
+
|
|
68
|
+
raw: dict[str, Any] | None = getattr(django_settings, "PYOBS_AUTH", None)
|
|
69
|
+
if not raw:
|
|
70
|
+
raise ImproperlyConfiguredError("PYOBS_AUTH setting is missing")
|
|
71
|
+
|
|
72
|
+
missing = [key for key in ("SERVER_URL", "REALM", "CLIENT_ID") if not raw.get(key)]
|
|
73
|
+
if missing:
|
|
74
|
+
raise ImproperlyConfiguredError(f"PYOBS_AUTH is missing required key(s): {', '.join(missing)}")
|
|
75
|
+
|
|
76
|
+
return KeycloakSettings(
|
|
77
|
+
server_url=raw["SERVER_URL"],
|
|
78
|
+
realm=raw["REALM"],
|
|
79
|
+
client_id=raw["CLIENT_ID"],
|
|
80
|
+
client_secret=raw.get("CLIENT_SECRET"),
|
|
81
|
+
audience=raw.get("AUDIENCE"),
|
|
82
|
+
redirect_uri=raw.get("REDIRECT_URI"),
|
|
83
|
+
scopes=tuple(raw.get("SCOPES", ("openid", "profile", "email"))),
|
|
84
|
+
user_resolver=raw.get("USER_RESOLVER"),
|
|
85
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Local, stateless bearer-token validation against a Keycloak realm's JWKS.
|
|
2
|
+
|
|
3
|
+
No per-request network round-trip to Keycloak (unlike introspection-style validation) - the
|
|
4
|
+
realm's signing keys are fetched once and cached (PyJWKClient does its own key-id-keyed caching,
|
|
5
|
+
refetching only on a cache miss, e.g. after key rotation).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import jwt
|
|
13
|
+
from jwt import PyJWKClient
|
|
14
|
+
|
|
15
|
+
from .discovery import fetch_discovery_document
|
|
16
|
+
from .settings import KeycloakSettings
|
|
17
|
+
|
|
18
|
+
_jwk_clients: dict[str, PyJWKClient] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TokenValidationError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clear_jwk_client_cache() -> None:
|
|
26
|
+
"""Only needed for tests - the cache is otherwise process-lifetime."""
|
|
27
|
+
_jwk_clients.clear()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _jwk_client_for(jwks_uri: str) -> PyJWKClient:
|
|
31
|
+
client = _jwk_clients.get(jwks_uri)
|
|
32
|
+
if client is None:
|
|
33
|
+
client = PyJWKClient(jwks_uri)
|
|
34
|
+
_jwk_clients[jwks_uri] = client
|
|
35
|
+
return client
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TokenValidator:
|
|
39
|
+
"""Validates bearer tokens issued by one Keycloak realm (see KeycloakSettings)."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, settings: KeycloakSettings) -> None:
|
|
42
|
+
self._settings = settings
|
|
43
|
+
|
|
44
|
+
def unverified_issuer(self, token: str) -> str | None:
|
|
45
|
+
"""Peek at the (unverified) `iss` claim without validating the token.
|
|
46
|
+
|
|
47
|
+
Lets a caller cheaply tell "not mine" from "mine but invalid" before deciding whether to
|
|
48
|
+
raise - relevant if multiple authenticators are ever stacked against the same Bearer
|
|
49
|
+
header (see pyobs-core's shared-auth design doc for why that matters).
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
claims = jwt.decode(token, options={"verify_signature": False, "verify_aud": False, "verify_exp": False})
|
|
53
|
+
except jwt.InvalidTokenError:
|
|
54
|
+
return None
|
|
55
|
+
return claims.get("iss")
|
|
56
|
+
|
|
57
|
+
def validate(self, token: str) -> dict[str, Any]:
|
|
58
|
+
document = fetch_discovery_document(self._settings.discovery_url)
|
|
59
|
+
jwk_client = _jwk_client_for(document.jwks_uri)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
signing_key = jwk_client.get_signing_key_from_jwt(token)
|
|
63
|
+
except jwt.PyJWKClientError as exc:
|
|
64
|
+
raise TokenValidationError(f"could not resolve signing key: {exc}") from exc
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
claims: dict[str, Any] = jwt.decode(
|
|
68
|
+
token,
|
|
69
|
+
signing_key.key,
|
|
70
|
+
algorithms=["RS256"],
|
|
71
|
+
issuer=document.issuer,
|
|
72
|
+
audience=self._settings.expected_audience,
|
|
73
|
+
options={"require": ["exp", "iat", "sub"]},
|
|
74
|
+
)
|
|
75
|
+
except jwt.InvalidTokenError as exc:
|
|
76
|
+
raise TokenValidationError(str(exc)) from exc
|
|
77
|
+
|
|
78
|
+
return claims
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Browser-facing login flow: authorization-code + PKCE redirect to Keycloak and back.
|
|
2
|
+
|
|
3
|
+
Not a django.contrib.auth AUTHENTICATION_BACKENDS class - that shape fits a synchronous
|
|
4
|
+
credential check (username+password in, User out), not a redirect-based OIDC flow. These are
|
|
5
|
+
plain Django views instead; wire them in via pyobs_auth.urls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from django.conf import settings as django_settings
|
|
11
|
+
from django.contrib.auth import login
|
|
12
|
+
from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
|
|
13
|
+
from django.views import View
|
|
14
|
+
|
|
15
|
+
from .client import KeycloakClient, TokenExchangeError
|
|
16
|
+
from .settings import get_settings
|
|
17
|
+
from .validation import TokenValidationError, TokenValidator
|
|
18
|
+
|
|
19
|
+
SESSION_STATE_KEY = "pyobs_auth_state"
|
|
20
|
+
SESSION_CODE_VERIFIER_KEY = "pyobs_auth_code_verifier"
|
|
21
|
+
SESSION_NEXT_KEY = "pyobs_auth_next"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LoginView(View):
|
|
25
|
+
def get(self, request: HttpRequest) -> HttpResponse:
|
|
26
|
+
settings = get_settings()
|
|
27
|
+
client = KeycloakClient(settings)
|
|
28
|
+
authorization = client.start_authorization()
|
|
29
|
+
|
|
30
|
+
request.session[SESSION_STATE_KEY] = authorization.state
|
|
31
|
+
request.session[SESSION_CODE_VERIFIER_KEY] = authorization.code_verifier
|
|
32
|
+
request.session[SESSION_NEXT_KEY] = request.GET.get("next", "/")
|
|
33
|
+
|
|
34
|
+
return HttpResponseRedirect(authorization.url)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CallbackView(View):
|
|
38
|
+
def get(self, request: HttpRequest) -> HttpResponse:
|
|
39
|
+
error = request.GET.get("error")
|
|
40
|
+
if error:
|
|
41
|
+
return HttpResponseBadRequest(f"Keycloak login failed: {error}")
|
|
42
|
+
|
|
43
|
+
code = request.GET.get("code")
|
|
44
|
+
state = request.GET.get("state")
|
|
45
|
+
expected_state = request.session.pop(SESSION_STATE_KEY, None)
|
|
46
|
+
code_verifier = request.session.pop(SESSION_CODE_VERIFIER_KEY, None)
|
|
47
|
+
next_url = request.session.pop(SESSION_NEXT_KEY, "/")
|
|
48
|
+
|
|
49
|
+
if not code or not state or not code_verifier or state != expected_state:
|
|
50
|
+
return HttpResponseBadRequest("Invalid or expired login state")
|
|
51
|
+
|
|
52
|
+
settings = get_settings()
|
|
53
|
+
client = KeycloakClient(settings)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
tokens = client.exchange_code(code=code, code_verifier=code_verifier)
|
|
57
|
+
except TokenExchangeError as exc:
|
|
58
|
+
return HttpResponseBadRequest(f"Token exchange failed: {exc}")
|
|
59
|
+
|
|
60
|
+
access_token = tokens.get("access_token")
|
|
61
|
+
if not access_token:
|
|
62
|
+
return HttpResponseBadRequest("No access_token in Keycloak's response")
|
|
63
|
+
|
|
64
|
+
validator = TokenValidator(settings)
|
|
65
|
+
try:
|
|
66
|
+
claims = validator.validate(access_token)
|
|
67
|
+
except TokenValidationError as exc:
|
|
68
|
+
return HttpResponseBadRequest(f"Received an invalid token: {exc}")
|
|
69
|
+
|
|
70
|
+
user_resolver = settings.resolve_user_callable()
|
|
71
|
+
user = user_resolver(claims)
|
|
72
|
+
if user is None:
|
|
73
|
+
return HttpResponseBadRequest("No local user for this token")
|
|
74
|
+
|
|
75
|
+
backend = getattr(django_settings, "PYOBS_AUTH_LOGIN_BACKEND", "django.contrib.auth.backends.ModelBackend")
|
|
76
|
+
login(request, user, backend=backend)
|
|
77
|
+
|
|
78
|
+
return HttpResponseRedirect(next_url)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyobs-auth"
|
|
3
|
+
version = "2.0.0.dev0"
|
|
4
|
+
description = "Shared Keycloak/OIDC authentication client for pyobs web services"
|
|
5
|
+
authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }]
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"Django>=5.2,<6",
|
|
10
|
+
"djangorestframework>=3.15",
|
|
11
|
+
"requests>=2.32.3,<3",
|
|
12
|
+
"PyJWT[crypto]>=2.10.1,<3",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.3.5,<10",
|
|
18
|
+
"pytest-django>=4.9.0,<5",
|
|
19
|
+
"pytest-cov>=6.1.1,<8",
|
|
20
|
+
"black>=25.1.0,<27",
|
|
21
|
+
"ruff>=0.15.16",
|
|
22
|
+
"pyrefly>=1.1.1",
|
|
23
|
+
"responses>=0.25.0,<1",
|
|
24
|
+
"types-requests>=2.32.0.20250328,<3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.sdist]
|
|
28
|
+
include = ["pyobs_auth"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
include = ["pyobs_auth"]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
"pyobs_auth" = ["py.typed"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
target-version = "py311"
|
|
42
|
+
line-length = 120
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["E", "F", "W", "I", "UP", "G"]
|
|
46
|
+
|
|
47
|
+
[tool.black]
|
|
48
|
+
line-length = 120
|
|
49
|
+
target-version = ['py311']
|
|
50
|
+
include = '\.pyi?$'
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
DJANGO_SETTINGS_MODULE = "tests.django_settings"
|
|
54
|
+
addopts = "-ra"
|