pyobs-auth 2.0.0.dev0__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 +11 -0
- pyobs_auth/apps.py +6 -0
- pyobs_auth/authentication.py +45 -0
- pyobs_auth/client.py +119 -0
- pyobs_auth/discovery.py +51 -0
- pyobs_auth/py.typed +0 -0
- pyobs_auth/settings.py +85 -0
- pyobs_auth/urls.py +10 -0
- pyobs_auth/validation.py +78 -0
- pyobs_auth/views.py +78 -0
- pyobs_auth-2.0.0.dev0.dist-info/METADATA +12 -0
- pyobs_auth-2.0.0.dev0.dist-info/RECORD +14 -0
- pyobs_auth-2.0.0.dev0.dist-info/WHEEL +4 -0
- pyobs_auth-2.0.0.dev0.dist-info/licenses/LICENSE +22 -0
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,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}"'
|
pyobs_auth/client.py
ADDED
|
@@ -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()
|
pyobs_auth/discovery.py
ADDED
|
@@ -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,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
|
+
)
|
pyobs_auth/urls.py
ADDED
pyobs_auth/validation.py
ADDED
|
@@ -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,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,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,14 @@
|
|
|
1
|
+
pyobs_auth/__init__.py,sha256=vCI7zULdAAll7fiHPhxlra7xfwbPvPAZwtITHExPYn8,280
|
|
2
|
+
pyobs_auth/apps.py,sha256=gWeEqL9TK3x6owVKHbnUujttu3Nt-qGcquQC7wLb4nY,151
|
|
3
|
+
pyobs_auth/authentication.py,sha256=ejjWWvg39COSUVRxyTX0pOpOYjZSyNNzJ3_0Npk03nY,1708
|
|
4
|
+
pyobs_auth/client.py,sha256=6WltZrcnJNahwpProomt-q4cDeaq5VRhnerEiTGg61Q,4648
|
|
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=MhHnhsdrlhpLzC7GhVjQEaMhxKz6aDeUFju1J5ieJjM,3051
|
|
8
|
+
pyobs_auth/urls.py,sha256=6fMyNdX7jDyl-wVUXAyUG4HjJngrfR_rk9i2PKt0lGA,236
|
|
9
|
+
pyobs_auth/validation.py,sha256=cOuG6kl4_O03BWACJZfhsPLuEYQvj36FxBYk-adneyI,2673
|
|
10
|
+
pyobs_auth/views.py,sha256=euZ433EcO0mrBoTCmCbRHCrKmbyR0bV1HvIVa6V8lxA,3123
|
|
11
|
+
pyobs_auth-2.0.0.dev0.dist-info/METADATA,sha256=a13okJuEHBtqPzenRZz7ZPFDDAvEP2FNjgN7r0D7xAg,410
|
|
12
|
+
pyobs_auth-2.0.0.dev0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
13
|
+
pyobs_auth-2.0.0.dev0.dist-info/licenses/LICENSE,sha256=Gi2yBbEZraESTIWbTnc4elzshQaXpW4QBs9FBotLG7A,1099
|
|
14
|
+
pyobs_auth-2.0.0.dev0.dist-info/RECORD,,
|
|
@@ -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.
|