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.
@@ -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,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class PyobsAuthConfig(AppConfig):
5
+ name = "pyobs_auth"
6
+ default_auto_field = "django.db.models.BigAutoField"
@@ -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,10 @@
1
+ from django.urls import path
2
+
3
+ from .views import CallbackView, LoginView
4
+
5
+ app_name = "pyobs_auth"
6
+
7
+ urlpatterns = [
8
+ path("login/", LoginView.as_view(), name="login"),
9
+ path("callback/", CallbackView.as_view(), name="callback"),
10
+ ]
@@ -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"