pyobs-auth 2.0.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
pyobs_auth/apps.py ADDED
@@ -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,64 @@
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/portal/etc. only ever need to trust one
7
+ issuer.
8
+
9
+ This class is written to be safe to stack alongside another Bearer-scheme authenticator that
10
+ can't be modified (e.g. an existing legacy OAuth2 BearerAuthentication) - see below.
11
+
12
+ A resolved user with `is_active=False` is refused - USER_RESOLVER implementations mint new
13
+ accounts inactive by convention, giving each service an independent local activation gate on top
14
+ of whatever access control Keycloak itself does (a kill switch that doesn't depend on Keycloak
15
+ realm/client config alone).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from rest_framework import authentication, exceptions
21
+
22
+ from .settings import get_settings
23
+ from .validation import TokenValidationError, TokenValidator
24
+
25
+
26
+ class KeycloakAuthentication(authentication.BaseAuthentication):
27
+ www_authenticate_realm = "api"
28
+
29
+ def authenticate(self, request):
30
+ auth_header = authentication.get_authorization_header(request).split()
31
+ if not auth_header or auth_header[0].lower() != b"bearer":
32
+ return None
33
+ if len(auth_header) != 2:
34
+ raise exceptions.AuthenticationFailed("Invalid Authorization header")
35
+
36
+ token = auth_header[1].decode()
37
+ settings = get_settings()
38
+ validator = TokenValidator(settings)
39
+
40
+ # DRF stops the whole authenticator chain on a raise, unlike a `None` return, which just
41
+ # falls through to the next class. If another Bearer-scheme authenticator is also
42
+ # registered (e.g. a legacy OAuth2 BearerAuthentication that can't be modified to do the
43
+ # same check), a token that was never meant for us must not block it from getting a turn -
44
+ # so defer (return None) for anything that doesn't even claim to be from our issuer, and
45
+ # only raise once we know the token was meant for us but is actually invalid.
46
+ if validator.unverified_issuer(token) != settings.issuer:
47
+ return None
48
+
49
+ try:
50
+ claims = validator.validate(token)
51
+ except TokenValidationError as exc:
52
+ raise exceptions.AuthenticationFailed(str(exc)) from exc
53
+
54
+ user_resolver = settings.resolve_user_callable()
55
+ user = user_resolver(claims)
56
+ if user is None:
57
+ raise exceptions.AuthenticationFailed("No local user for this token")
58
+ if not user.is_active:
59
+ raise exceptions.AuthenticationFailed("Account pending activation")
60
+
61
+ return (user, claims)
62
+
63
+ def authenticate_header(self, request):
64
+ return f'Bearer realm="{self.www_authenticate_realm}"'
pyobs_auth/client.py ADDED
@@ -0,0 +1,143 @@
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(
46
+ self, *, idp_hint: str | None = None, redirect_uri: str | None = None
47
+ ) -> AuthorizationRequest:
48
+ """Build the redirect URL for the authorization-code + PKCE login flow."""
49
+ document = self._discovery()
50
+ state = _b64url(secrets.token_bytes(24))
51
+ code_verifier = _b64url(secrets.token_bytes(48))
52
+ code_challenge = _b64url(hashlib.sha256(code_verifier.encode("ascii")).digest())
53
+
54
+ effective_redirect_uri = redirect_uri or self._settings.redirect_uri
55
+ if not effective_redirect_uri:
56
+ raise ValueError("redirect_uri must be set (either PYOBS_AUTH['REDIRECT_URI'] or the redirect_uri arg)")
57
+
58
+ params = {
59
+ "response_type": "code",
60
+ "client_id": self._settings.client_id,
61
+ "redirect_uri": effective_redirect_uri,
62
+ "scope": " ".join(self._settings.scopes),
63
+ "state": state,
64
+ "code_challenge": code_challenge,
65
+ "code_challenge_method": "S256",
66
+ }
67
+ if idp_hint:
68
+ # kc_idp_hint: Keycloak skips its login/IdP-selection page and redirects straight to
69
+ # that identity provider; unknown aliases fall back to the normal login page.
70
+ params["kc_idp_hint"] = idp_hint
71
+ url = f"{document.authorization_endpoint}?{urlencode(params)}"
72
+ return AuthorizationRequest(url=url, state=state, code_verifier=code_verifier)
73
+
74
+ def exchange_code(self, *, code: str, code_verifier: str, redirect_uri: str | None = None) -> dict[str, Any]:
75
+ """Swap an authorization code for tokens (access_token, refresh_token, id_token, ...)."""
76
+ document = self._discovery()
77
+ effective_redirect_uri = redirect_uri or self._settings.redirect_uri
78
+ if not effective_redirect_uri:
79
+ raise ValueError("redirect_uri must be set (either PYOBS_AUTH['REDIRECT_URI'] or the redirect_uri arg)")
80
+
81
+ data = {
82
+ "grant_type": "authorization_code",
83
+ "code": code,
84
+ "redirect_uri": effective_redirect_uri,
85
+ "client_id": self._settings.client_id,
86
+ "code_verifier": code_verifier,
87
+ }
88
+ if self._settings.client_secret:
89
+ data["client_secret"] = self._settings.client_secret
90
+
91
+ return self._post_token(document.token_endpoint, data)
92
+
93
+ def client_credentials_token(self, *, scope: str | None = None) -> dict[str, Any]:
94
+ """Service-to-service token, e.g. for one pyobs web service to call another's API."""
95
+ if not self._settings.client_secret:
96
+ raise ValueError("client_credentials grant requires PYOBS_AUTH['CLIENT_SECRET']")
97
+
98
+ document = self._discovery()
99
+ data = {
100
+ "grant_type": "client_credentials",
101
+ "client_id": self._settings.client_id,
102
+ "client_secret": self._settings.client_secret,
103
+ }
104
+ if scope:
105
+ data["scope"] = scope
106
+
107
+ return self._post_token(document.token_endpoint, data)
108
+
109
+ def refresh(self, *, refresh_token: str) -> dict[str, Any]:
110
+ document = self._discovery()
111
+ data = {
112
+ "grant_type": "refresh_token",
113
+ "refresh_token": refresh_token,
114
+ "client_id": self._settings.client_id,
115
+ }
116
+ if self._settings.client_secret:
117
+ data["client_secret"] = self._settings.client_secret
118
+
119
+ return self._post_token(document.token_endpoint, data)
120
+
121
+ def end_session_url(self, *, id_token_hint: str, post_logout_redirect_uri: str | None = None) -> str:
122
+ """RP-Initiated Logout: URL to send the browser to end the user's Keycloak SSO session.
123
+
124
+ `id_token_hint` lets Keycloak log the user out with a single redirect instead of showing
125
+ a confirmation page - so an id_token needs to have been kept around from login for this
126
+ to give a clean one-click logout.
127
+ """
128
+ document = self._discovery()
129
+ if not document.end_session_endpoint:
130
+ raise ValueError("this Keycloak realm did not advertise an end_session_endpoint")
131
+
132
+ effective_redirect_uri = post_logout_redirect_uri or self._settings.post_logout_redirect_uri
133
+ params = {"id_token_hint": id_token_hint, "client_id": self._settings.client_id}
134
+ if effective_redirect_uri:
135
+ params["post_logout_redirect_uri"] = effective_redirect_uri
136
+
137
+ return f"{document.end_session_endpoint}?{urlencode(params)}"
138
+
139
+ def _post_token(self, token_endpoint: str, data: dict[str, str]) -> dict[str, Any]:
140
+ response = self._session.post(token_endpoint, data=data, timeout=10.0)
141
+ if response.status_code != 200:
142
+ raise TokenExchangeError(f"token endpoint returned {response.status_code}: {response.text}")
143
+ 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()
pyobs_auth/py.typed ADDED
File without changes
pyobs_auth/settings.py ADDED
@@ -0,0 +1,90 @@
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
+ "POST_LOGOUT_REDIRECT_URI": "https://archive.example.org/",
12
+ # dotted path to a callable(claims: dict) -> django.contrib.auth.models.User
13
+ "USER_RESOLVER": "pyobs_archive.authentication.keycloak.resolve_user",
14
+ }
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass, field
21
+ from importlib import import_module
22
+ from typing import Any
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class KeycloakSettings:
27
+ server_url: str
28
+ realm: str
29
+ client_id: str
30
+ client_secret: str | None = None
31
+ audience: str | None = None
32
+ redirect_uri: str | None = None
33
+ post_logout_redirect_uri: str | None = None
34
+ scopes: tuple[str, ...] = field(default_factory=lambda: ("openid", "profile", "email"))
35
+ user_resolver: str | None = None
36
+ idp_hint: str | None = None
37
+
38
+ @property
39
+ def issuer(self) -> str:
40
+ return f"{self.server_url.rstrip('/')}/realms/{self.realm}"
41
+
42
+ @property
43
+ def discovery_url(self) -> str:
44
+ return f"{self.issuer}/.well-known/openid-configuration"
45
+
46
+ @property
47
+ def expected_audience(self) -> str:
48
+ return self.audience or self.client_id
49
+
50
+ def resolve_user_callable(self) -> Callable[[dict[str, Any]], Any]:
51
+ if not self.user_resolver:
52
+ raise ImproperlyConfiguredError(
53
+ "PYOBS_AUTH['USER_RESOLVER'] is not set - pyobs-auth needs a"
54
+ " callable(claims: dict) -> User to map a validated token to a local user"
55
+ )
56
+ module_path, _, attr = self.user_resolver.rpartition(".")
57
+ if not module_path:
58
+ raise ImproperlyConfiguredError(f"PYOBS_AUTH['USER_RESOLVER'] is not a dotted path: {self.user_resolver!r}")
59
+ module = import_module(module_path)
60
+ return getattr(module, attr)
61
+
62
+
63
+ class ImproperlyConfiguredError(Exception):
64
+ pass
65
+
66
+
67
+ def get_settings() -> KeycloakSettings:
68
+ """Build KeycloakSettings from Django's PYOBS_AUTH setting."""
69
+ from django.conf import settings as django_settings
70
+
71
+ raw: dict[str, Any] | None = getattr(django_settings, "PYOBS_AUTH", None)
72
+ if not raw:
73
+ raise ImproperlyConfiguredError("PYOBS_AUTH setting is missing")
74
+
75
+ missing = [key for key in ("SERVER_URL", "REALM", "CLIENT_ID") if not raw.get(key)]
76
+ if missing:
77
+ raise ImproperlyConfiguredError(f"PYOBS_AUTH is missing required key(s): {', '.join(missing)}")
78
+
79
+ return KeycloakSettings(
80
+ server_url=raw["SERVER_URL"],
81
+ realm=raw["REALM"],
82
+ client_id=raw["CLIENT_ID"],
83
+ client_secret=raw.get("CLIENT_SECRET"),
84
+ audience=raw.get("AUDIENCE"),
85
+ redirect_uri=raw.get("REDIRECT_URI"),
86
+ post_logout_redirect_uri=raw.get("POST_LOGOUT_REDIRECT_URI"),
87
+ scopes=tuple(raw.get("SCOPES", ("openid", "profile", "email"))),
88
+ user_resolver=raw.get("USER_RESOLVER"),
89
+ idp_hint=raw.get("IDP_HINT"),
90
+ )
@@ -0,0 +1,50 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sign in failed</title>
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ body {
10
+ margin: 0;
11
+ min-height: 100vh;
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ background: #0d1117;
16
+ color: #c9d1d9;
17
+ font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
18
+ }
19
+ .card {
20
+ width: 100%;
21
+ max-width: 380px;
22
+ margin: 1rem;
23
+ padding: 1.5rem;
24
+ background: #161b22;
25
+ border: 1px solid #30363d;
26
+ border-radius: 0.5rem;
27
+ box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.4);
28
+ text-align: center;
29
+ }
30
+ h1 { font-size: 1.1rem; margin: 0 0 0.5rem; color: #f0f6fc; }
31
+ p.message { margin: 0 0 1.25rem; color: #8b949e; }
32
+ a.back {
33
+ display: block;
34
+ padding: 0.5rem 1rem;
35
+ border: 1px solid #30363d;
36
+ border-radius: 0.375rem;
37
+ color: #c9d1d9;
38
+ text-decoration: none;
39
+ }
40
+ a.back:hover { background: #21262d; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <div class="card">
45
+ <h1>Sign in failed</h1>
46
+ <p class="message">{{ message }}</p>
47
+ <a class="back" href="{{ back_url }}">Back to sign in</a>
48
+ </div>
49
+ </body>
50
+ </html>
pyobs_auth/urls.py ADDED
@@ -0,0 +1,11 @@
1
+ from django.urls import path
2
+
3
+ from .views import CallbackView, LoginView, LogoutView
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
+ path("logout/", LogoutView.as_view(), name="logout"),
11
+ ]
@@ -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
pyobs_auth/views.py ADDED
@@ -0,0 +1,128 @@
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, logout
12
+ from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
13
+ from django.shortcuts import render
14
+ from django.views import View
15
+
16
+ from .client import KeycloakClient, TokenExchangeError
17
+ from .settings import get_settings
18
+ from .validation import TokenValidationError, TokenValidator
19
+
20
+ SESSION_STATE_KEY = "pyobs_auth_state"
21
+ SESSION_CODE_VERIFIER_KEY = "pyobs_auth_code_verifier"
22
+ SESSION_NEXT_KEY = "pyobs_auth_next"
23
+ # Presence of this key is also how LogoutView tells "this session came from Keycloak" apart from
24
+ # a plain local-password session, so it knows whether to also end the Keycloak SSO session.
25
+ SESSION_ID_TOKEN_KEY = "pyobs_auth_id_token"
26
+
27
+
28
+ def _error_response(request: HttpRequest, message: str) -> HttpResponse:
29
+ """A styled error page rather than a bare 400 - pyobs_auth doesn't know the host app's own
30
+ login URL name (each service names it differently), so this always links back to "/", which
31
+ every consuming service already bounces an unauthenticated visitor to its own login page
32
+ from."""
33
+ return render(request, "pyobs_auth/error.html", {"message": message, "back_url": "/"}, status=400)
34
+
35
+
36
+ class LoginView(View):
37
+ def get(self, request: HttpRequest) -> HttpResponse:
38
+ settings = get_settings()
39
+ client = KeycloakClient(settings)
40
+ # ?idp_hint= handling: absent -> the configured default hint (fast path); present but
41
+ # empty -> no hint (local Keycloak account); any value -> that specific hint.
42
+ idp_hint = request.GET.get("idp_hint")
43
+ if idp_hint is None:
44
+ idp_hint = settings.idp_hint
45
+ authorization = client.start_authorization(idp_hint=idp_hint or None)
46
+
47
+ request.session[SESSION_STATE_KEY] = authorization.state
48
+ request.session[SESSION_CODE_VERIFIER_KEY] = authorization.code_verifier
49
+ # `or "/"` (not just a dict default) because `?next=` with an empty value is a present-but-
50
+ # falsy key - `.get("next", "/")` alone would return "" instead of falling back to "/".
51
+ request.session[SESSION_NEXT_KEY] = request.GET.get("next") or "/"
52
+
53
+ return HttpResponseRedirect(authorization.url)
54
+
55
+
56
+ class CallbackView(View):
57
+ def get(self, request: HttpRequest) -> HttpResponse:
58
+ error = request.GET.get("error")
59
+ if error:
60
+ return _error_response(request, f"Keycloak login failed: {error}")
61
+
62
+ code = request.GET.get("code")
63
+ state = request.GET.get("state")
64
+ expected_state = request.session.pop(SESSION_STATE_KEY, None)
65
+ code_verifier = request.session.pop(SESSION_CODE_VERIFIER_KEY, None)
66
+ next_url = request.session.pop(SESSION_NEXT_KEY, "/") or "/"
67
+
68
+ if not code or not state or not code_verifier or state != expected_state:
69
+ return _error_response(request, "Invalid or expired login state")
70
+
71
+ settings = get_settings()
72
+ client = KeycloakClient(settings)
73
+
74
+ try:
75
+ tokens = client.exchange_code(code=code, code_verifier=code_verifier)
76
+ except TokenExchangeError as exc:
77
+ return _error_response(request, f"Token exchange failed: {exc}")
78
+
79
+ access_token = tokens.get("access_token")
80
+ if not access_token:
81
+ return _error_response(request, "No access_token in Keycloak's response")
82
+
83
+ validator = TokenValidator(settings)
84
+ try:
85
+ claims = validator.validate(access_token)
86
+ except TokenValidationError as exc:
87
+ return _error_response(request, f"Received an invalid token: {exc}")
88
+
89
+ user_resolver = settings.resolve_user_callable()
90
+ user = user_resolver(claims)
91
+ if user is None:
92
+ return _error_response(request, "No local user for this token")
93
+ if not user.is_active:
94
+ return _error_response(request, "This account is pending activation. Contact an administrator.")
95
+
96
+ backend = getattr(django_settings, "PYOBS_AUTH_LOGIN_BACKEND", "django.contrib.auth.backends.ModelBackend")
97
+ login(request, user, backend=backend)
98
+ # Set after login(), which rotates the session key - setting it before would risk the
99
+ # value getting lost if that rotation ever stopped preserving existing session data.
100
+ id_token = tokens.get("id_token")
101
+ if id_token:
102
+ request.session[SESSION_ID_TOKEN_KEY] = id_token
103
+
104
+ return HttpResponseRedirect(next_url)
105
+
106
+
107
+ class LogoutView(View):
108
+ """Ends the local Django session and, only if this session was established via Keycloak
109
+ (an id_token was stored at login), also ends the Keycloak SSO session via RP-Initiated
110
+ Logout - so a plain local-password session just gets an ordinary local logout, unaffected."""
111
+
112
+ http_method_names = ["post"]
113
+
114
+ def post(self, request: HttpRequest) -> HttpResponse:
115
+ id_token = request.session.pop(SESSION_ID_TOKEN_KEY, None)
116
+ logout(request)
117
+
118
+ if id_token is None:
119
+ next_url = request.POST.get("next") or request.GET.get("next") or "/"
120
+ return HttpResponseRedirect(next_url)
121
+
122
+ # post_logout_redirect_uri here deliberately comes only from PYOBS_AUTH (a fixed,
123
+ # pre-registered absolute URL, like REDIRECT_URI for login) rather than from a per-request
124
+ # `next` - Keycloak validates it against "Valid post logout redirect URIs" for the client,
125
+ # so an arbitrary relative path wouldn't match and the redirect would fail.
126
+ settings = get_settings()
127
+ client = KeycloakClient(settings)
128
+ return HttpResponseRedirect(client.end_session_url(id_token_hint=id_token))
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyobs-auth
3
+ Version: 2.0.0
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<7,>=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,15 @@
1
+ pyobs_auth/__init__.py,sha256=vCI7zULdAAll7fiHPhxlra7xfwbPvPAZwtITHExPYn8,280
2
+ pyobs_auth/apps.py,sha256=gWeEqL9TK3x6owVKHbnUujttu3Nt-qGcquQC7wLb4nY,151
3
+ pyobs_auth/authentication.py,sha256=Em1N5pyErKQGIMsIZQT9vhL0tTE0OplQq7d89qZA8vQ,2958
4
+ pyobs_auth/client.py,sha256=1tYgYkm8D123ywhHYkaqk9tcVR6PLGpYbezQd2FJqjo,5941
5
+ pyobs_auth/discovery.py,sha256=a7d6uqA8ebMHrRrfy-pHNvyP_RRAO87UOuAryiKiico,1360
6
+ pyobs_auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pyobs_auth/settings.py,sha256=VtaNJUesPUa6B5wLJ-ksqXr29Y2jSp6cgMFG7WMoa7o,3307
8
+ pyobs_auth/urls.py,sha256=D8IuMoxOCjkqf7JzZyuzJQ1B7VNoi1XIHgTZNGBTWGc,306
9
+ pyobs_auth/validation.py,sha256=cOuG6kl4_O03BWACJZfhsPLuEYQvj36FxBYk-adneyI,2673
10
+ pyobs_auth/views.py,sha256=6F-rM4df_iZom2eA9PeOKAGlV9ZkkKe2m-3OXubqYJQ,6028
11
+ pyobs_auth/templates/pyobs_auth/error.html,sha256=QZkE2WZx2GVBGI5VcY-GUxR6QL0hQzBVQn8kTMwep7o,1306
12
+ pyobs_auth-2.0.0.dist-info/METADATA,sha256=-OJW06VEeGR5ybhp7uqTAgAr_4IEPc0ovyiR6EgI74k,405
13
+ pyobs_auth-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ pyobs_auth-2.0.0.dist-info/licenses/LICENSE,sha256=Gi2yBbEZraESTIWbTnc4elzshQaXpW4QBs9FBotLG7A,1099
15
+ pyobs_auth-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.