pyobs-auth 2.0.0.dev10__tar.gz → 2.1.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-auth
3
- Version: 2.0.0.dev10
3
+ Version: 2.1.0
4
4
  Summary: Shared Keycloak/OIDC authentication client for pyobs web services
5
5
  Author-email: Tim-Oliver Husser <thusser@uni-goettingen.de>
6
6
  License-Expression: MIT
@@ -9,19 +9,25 @@ issuer.
9
9
  This class is written to be safe to stack alongside another Bearer-scheme authenticator that
10
10
  can't be modified (e.g. an existing legacy OAuth2 BearerAuthentication) - see below.
11
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).
12
+ The primary authorization gate is claims-based (`PYOBS_AUTH['REQUIRED_GROUPS']`/`REQUIRED_ROLES'`,
13
+ see `pyobs_auth.authorization`) - Keycloak group/role membership carried in the token, checked
14
+ before the user's first login even needs to happen. The local `is_active` check only applies when
15
+ `PYOBS_AUTH['ENFORCE_LOCAL_ACTIVE']` is set, preserving it as an opt-in, Keycloak-independent kill
16
+ switch rather than the default gate.
16
17
  """
17
18
 
18
19
  from __future__ import annotations
19
20
 
21
+ import logging
22
+
20
23
  from rest_framework import authentication, exceptions
21
24
 
25
+ from .authorization import AuthorizationError, authorize
22
26
  from .settings import get_settings
23
27
  from .validation import TokenValidationError, TokenValidator
24
28
 
29
+ _logger = logging.getLogger(__name__)
30
+
25
31
 
26
32
  class KeycloakAuthentication(authentication.BaseAuthentication):
27
33
  www_authenticate_realm = "api"
@@ -51,11 +57,21 @@ class KeycloakAuthentication(authentication.BaseAuthentication):
51
57
  except TokenValidationError as exc:
52
58
  raise exceptions.AuthenticationFailed(str(exc)) from exc
53
59
 
60
+ # Checked before resolving a local user - an unauthorized caller must not mint a User
61
+ # row just by presenting a validly-signed-but-ungrouped token.
62
+ try:
63
+ authorize(claims, settings)
64
+ except AuthorizationError as exc:
65
+ raise exceptions.AuthenticationFailed(str(exc)) from exc
66
+ except ValueError:
67
+ _logger.exception("PYOBS_AUTH['REQUIRED_ROLES'] is malformed")
68
+ raise
69
+
54
70
  user_resolver = settings.resolve_user_callable()
55
71
  user = user_resolver(claims)
56
72
  if user is None:
57
73
  raise exceptions.AuthenticationFailed("No local user for this token")
58
- if not user.is_active:
74
+ if settings.enforce_local_active and not user.is_active:
59
75
  raise exceptions.AuthenticationFailed("Account pending activation")
60
76
 
61
77
  return (user, claims)
@@ -0,0 +1,61 @@
1
+ """Claims-based authorization gate: Keycloak group/role membership as the authorization source
2
+ of truth, replacing per-service local `is_active` activation. See pyobs-core's
3
+ `specs/design/shared-authz-keycloak.md` and ADR `0014-centralized-authorization-via-keycloak-groups.md`
4
+ for the reasoning.
5
+
6
+ Neither `REQUIRED_GROUPS` nor `REQUIRED_ROLES` set -> `authorize()` always passes (opt-in gate,
7
+ current behavior unchanged). Both set -> both must pass (AND, not OR) - there's no fleet use case
8
+ today that needs "either this group or that role", and OR would be a silent surprise for whoever
9
+ sets both expecting an AND.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from .settings import KeycloakSettings
17
+
18
+
19
+ class AuthorizationError(Exception):
20
+ pass
21
+
22
+
23
+ def _has_required_groups(claims: dict[str, Any], required_groups: tuple[str, ...]) -> bool:
24
+ groups = set(claims.get("groups") or [])
25
+ return all(group in groups for group in required_groups)
26
+
27
+
28
+ def _has_required_roles(claims: dict[str, Any], required_roles: tuple[str, ...]) -> bool:
29
+ realm_roles = set((claims.get("realm_access") or {}).get("roles") or [])
30
+ resource_access = claims.get("resource_access") or {}
31
+
32
+ for required in required_roles:
33
+ kind, _, name = required.partition(":")
34
+ if kind == "realm":
35
+ if name not in realm_roles:
36
+ return False
37
+ elif kind == "client":
38
+ client_id, _, role = name.partition(":")
39
+ client_roles = set((resource_access.get(client_id) or {}).get("roles") or [])
40
+ if role not in client_roles:
41
+ return False
42
+ else:
43
+ raise ValueError(
44
+ f"malformed PYOBS_AUTH['REQUIRED_ROLES'] entry {required!r} -"
45
+ " expected 'realm:<role>' or 'client:<client_id>:<role>'"
46
+ )
47
+ return True
48
+
49
+
50
+ def authorize(claims: dict[str, Any], settings: KeycloakSettings) -> None:
51
+ """Raise AuthorizationError unless `claims` satisfies every configured REQUIRED_GROUPS entry
52
+ (full group paths, e.g. `/pyobs-archive`) and every configured REQUIRED_ROLES entry (realm
53
+ roles as `realm:<role>`, client roles as `client:<client_id>:<role>`).
54
+ """
55
+ if not settings.required_groups and not settings.required_roles:
56
+ return
57
+
58
+ if not _has_required_groups(claims, settings.required_groups):
59
+ raise AuthorizationError("not authorized")
60
+ if not _has_required_roles(claims, settings.required_roles):
61
+ raise AuthorizationError("not authorized")
@@ -11,13 +11,22 @@ from typing import Any
11
11
  from urllib.parse import urlencode
12
12
 
13
13
  import requests
14
+ from requests.exceptions import RequestException
14
15
 
15
16
  from .discovery import fetch_discovery_document
16
17
  from .settings import KeycloakSettings
17
18
 
18
19
 
19
20
  class TokenExchangeError(Exception):
20
- pass
21
+ """`error_code` is the OAuth `error` field from the token endpoint's JSON body, when the
22
+ endpoint responded at all (e.g. "invalid_grant" for a revoked/expired/reused refresh token).
23
+ None for a connection-level failure or a non-JSON/unrecognized response - callers that need to
24
+ tell "genuinely revoked" apart from "couldn't even ask" should check this rather than assume
25
+ every TokenExchangeError means the former."""
26
+
27
+ def __init__(self, message: str, *, error_code: str | None = None) -> None:
28
+ super().__init__(message)
29
+ self.error_code = error_code
21
30
 
22
31
 
23
32
  def _b64url(raw: bytes) -> str:
@@ -137,7 +146,20 @@ class KeycloakClient:
137
146
  return f"{document.end_session_endpoint}?{urlencode(params)}"
138
147
 
139
148
  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)
149
+ try:
150
+ response = self._session.post(token_endpoint, data=data, timeout=10.0)
151
+ except RequestException as exc:
152
+ raise TokenExchangeError(f"could not reach token endpoint: {exc}") from exc
153
+
141
154
  if response.status_code != 200:
142
- raise TokenExchangeError(f"token endpoint returned {response.status_code}: {response.text}")
155
+ error_code = None
156
+ try:
157
+ body = response.json()
158
+ except ValueError:
159
+ body = None
160
+ if isinstance(body, dict):
161
+ error_code = body.get("error")
162
+ raise TokenExchangeError(
163
+ f"token endpoint returned {response.status_code}: {response.text}", error_code=error_code
164
+ )
143
165
  return response.json()
@@ -0,0 +1,152 @@
1
+ """Session-refresh middleware: once the access token that established a browser session has
2
+ expired, silently exchange the stored refresh token for a new one, re-validate the resulting
3
+ claims, and re-run the authorization gate - ending the session if it now fails.
4
+
5
+ Without this, a plain Django session never re-contacts Keycloak after login, so a revoked group/
6
+ role (or a synced local flag like `is_superuser`) would only take effect at the user's next login,
7
+ bounded only by `SESSION_COOKIE_AGE`, not by any token lifetime. See pyobs-core's
8
+ `specs/design/shared-authz-keycloak.md`, "Revocation model and freshness".
9
+
10
+ Runs lazily, on whichever request happens to land after expiry - no background task, and no
11
+ Keycloak round trip at all while the cached access token is still valid, so this doesn't add a
12
+ per-request network dependency.
13
+
14
+ Add to `MIDDLEWARE`, after `AuthenticationMiddleware` (needs `request.user`)::
15
+
16
+ MIDDLEWARE = [
17
+ ...,
18
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
19
+ "pyobs_auth.middleware.KeycloakSessionRefreshMiddleware",
20
+ ]
21
+
22
+ A no-op for any request whose session doesn't carry both a refresh token and an access-token
23
+ expiry - i.e. sessions never established via `CallbackView` (a local-password session), sessions
24
+ established before this middleware existed, and sessions where `CallbackView` declined to store
25
+ the refresh token at all (a cookie-backed `SESSION_ENGINE` - see `views.py`).
26
+
27
+ A failed refresh only ends the session when Keycloak's token endpoint says the grant is actually
28
+ invalid (`error: "invalid_grant"`) - a network error or a 5xx from Keycloak itself leaves the
29
+ session as-is and lets the next request retry, so a Keycloak outage doesn't mass-log-out every
30
+ active session. `invalid_grant` itself is ambiguous between "genuinely revoked" and "a harmless
31
+ race between two concurrent requests both refreshing the same soon-to-be-invalidated token" (only
32
+ relevant if the realm has refresh-token rotation/revocation enabled) - handled by re-reading the
33
+ session from its store before giving up.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import logging
39
+ import time
40
+ from collections.abc import Callable
41
+ from importlib import import_module
42
+
43
+ from django.conf import settings as django_settings
44
+ from django.contrib.auth import logout
45
+ from django.http import HttpRequest, HttpResponse
46
+
47
+ from .authorization import AuthorizationError, authorize
48
+ from .client import KeycloakClient, TokenExchangeError
49
+ from .settings import get_settings
50
+ from .validation import TokenValidationError, TokenValidator
51
+ from .views import SESSION_ACCESS_EXPIRES_KEY, SESSION_REFRESH_TOKEN_KEY
52
+
53
+ _logger = logging.getLogger(__name__)
54
+
55
+
56
+ class KeycloakSessionRefreshMiddleware:
57
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
58
+ self.get_response = get_response
59
+
60
+ def __call__(self, request: HttpRequest) -> HttpResponse:
61
+ self._maybe_refresh(request)
62
+ return self.get_response(request)
63
+
64
+ def _maybe_refresh(self, request: HttpRequest) -> None:
65
+ expires_at = request.session.get(SESSION_ACCESS_EXPIRES_KEY)
66
+ refresh_token = request.session.get(SESSION_REFRESH_TOKEN_KEY)
67
+ if expires_at is None or refresh_token is None:
68
+ return
69
+ if time.time() < expires_at:
70
+ return
71
+
72
+ settings = get_settings()
73
+ client = KeycloakClient(settings)
74
+ try:
75
+ tokens = client.refresh(refresh_token=refresh_token)
76
+ except TokenExchangeError as exc:
77
+ self._handle_refresh_failure(request, exc)
78
+ return
79
+
80
+ access_token = tokens.get("access_token")
81
+ if not access_token:
82
+ logout(request)
83
+ return
84
+
85
+ validator = TokenValidator(settings)
86
+ try:
87
+ claims = validator.validate(access_token)
88
+ except TokenValidationError:
89
+ logout(request)
90
+ return
91
+
92
+ if settings.enforce_local_active and not request.user.is_active:
93
+ logout(request)
94
+ return
95
+ try:
96
+ authorize(claims, settings)
97
+ except AuthorizationError:
98
+ logout(request)
99
+ return
100
+ except ValueError:
101
+ _logger.exception("PYOBS_AUTH['REQUIRED_ROLES'] is malformed")
102
+ raise
103
+
104
+ # Re-run the resolver so a claim-derived local flag (e.g. portal's `is_superuser` synced
105
+ # from a client role) picks up a change made in Keycloak since login, not just at next
106
+ # login - and so the *current* request sees it too, not only the next one.
107
+ user_resolver = settings.resolve_user_callable()
108
+ refreshed_user = user_resolver(claims)
109
+ if refreshed_user is None:
110
+ logout(request)
111
+ return
112
+ request.user = refreshed_user
113
+
114
+ request.session[SESSION_ACCESS_EXPIRES_KEY] = claims["exp"]
115
+ new_refresh_token = tokens.get("refresh_token")
116
+ if new_refresh_token:
117
+ request.session[SESSION_REFRESH_TOKEN_KEY] = new_refresh_token
118
+
119
+ def _handle_refresh_failure(self, request: HttpRequest, exc: TokenExchangeError) -> None:
120
+ if exc.error_code != "invalid_grant":
121
+ # Keycloak unreachable, a 5xx, a malformed response, etc. - not evidence of
122
+ # revocation. Leave the session as-is; the next request retries. No backoff/
123
+ # rate-limiting here - retrying every request during an outage is an accepted
124
+ # tradeoff at this fleet's scale. debug, not warning: during an outage this fires
125
+ # once per expired session per request, which would otherwise flood logs.
126
+ _logger.debug("Keycloak refresh_token grant failed (not invalid_grant): %s", exc)
127
+ return
128
+
129
+ # invalid_grant can mean a genuinely revoked/expired grant, or a benign race: two
130
+ # concurrent requests both saw the access token as expired and both tried to refresh the
131
+ # same refresh token - if the realm rotates/revokes refresh tokens on use, only the first
132
+ # succeeds and the second gets invalid_grant even though nothing was actually revoked.
133
+ # Re-read the session fresh from its store (not the in-memory request.session, which may
134
+ # be stale relative to what the concurrent request already wrote) before giving up.
135
+ fresh = self._fresh_session(request)
136
+ fresh_expires_at = fresh.get(SESSION_ACCESS_EXPIRES_KEY) if fresh is not None else None
137
+ if fresh_expires_at is not None and fresh_expires_at > time.time():
138
+ request.session[SESSION_ACCESS_EXPIRES_KEY] = fresh_expires_at
139
+ fresh_refresh_token = fresh.get(SESSION_REFRESH_TOKEN_KEY)
140
+ if fresh_refresh_token is not None:
141
+ request.session[SESSION_REFRESH_TOKEN_KEY] = fresh_refresh_token
142
+ return
143
+
144
+ logout(request)
145
+
146
+ @staticmethod
147
+ def _fresh_session(request: HttpRequest):
148
+ session_key = request.session.session_key
149
+ if not session_key:
150
+ return None
151
+ engine = import_module(django_settings.SESSION_ENGINE)
152
+ return engine.SessionStore(session_key=session_key)
@@ -11,6 +11,10 @@ Read from the Django ``PYOBS_AUTH`` setting, e.g.::
11
11
  "POST_LOGOUT_REDIRECT_URI": "https://archive.example.org/",
12
12
  # dotted path to a callable(claims: dict) -> django.contrib.auth.models.User
13
13
  "USER_RESOLVER": "pyobs_archive.authentication.keycloak.resolve_user",
14
+ # optional claims-based authorization gate - see pyobs_auth.authorization
15
+ "REQUIRED_GROUPS": ["/pyobs-archive"],
16
+ "REQUIRED_ROLES": ["client:archive:archive-admin"],
17
+ "ENFORCE_LOCAL_ACTIVE": False,
14
18
  }
15
19
  """
16
20
 
@@ -34,6 +38,9 @@ class KeycloakSettings:
34
38
  scopes: tuple[str, ...] = field(default_factory=lambda: ("openid", "profile", "email"))
35
39
  user_resolver: str | None = None
36
40
  idp_hint: str | None = None
41
+ required_groups: tuple[str, ...] = field(default_factory=tuple)
42
+ required_roles: tuple[str, ...] = field(default_factory=tuple)
43
+ enforce_local_active: bool = False
37
44
 
38
45
  @property
39
46
  def issuer(self) -> str:
@@ -64,6 +71,25 @@ class ImproperlyConfiguredError(Exception):
64
71
  pass
65
72
 
66
73
 
74
+ def _as_tuple(value: Any) -> tuple[str, ...]:
75
+ """A bare string (a common mistake for a "one group" REQUIRED_GROUPS/REQUIRED_ROLES value)
76
+ would otherwise silently `tuple()`-split into individual characters instead of raising -
77
+ normalize it to a one-element tuple instead."""
78
+ if value is None:
79
+ return ()
80
+ if isinstance(value, str):
81
+ return (value,)
82
+ return tuple(value)
83
+
84
+
85
+ def _as_bool(value: Any) -> bool:
86
+ """`bool("False")` is True - a string value (e.g. from naive env-var parsing) needs an
87
+ explicit check rather than Python's default truthiness."""
88
+ if isinstance(value, str):
89
+ return value.strip().lower() in ("true", "1", "yes")
90
+ return bool(value)
91
+
92
+
67
93
  def get_settings() -> KeycloakSettings:
68
94
  """Build KeycloakSettings from Django's PYOBS_AUTH setting."""
69
95
  from django.conf import settings as django_settings
@@ -87,4 +113,7 @@ def get_settings() -> KeycloakSettings:
87
113
  scopes=tuple(raw.get("SCOPES", ("openid", "profile", "email"))),
88
114
  user_resolver=raw.get("USER_RESOLVER"),
89
115
  idp_hint=raw.get("IDP_HINT"),
116
+ required_groups=_as_tuple(raw.get("REQUIRED_GROUPS")),
117
+ required_roles=_as_tuple(raw.get("REQUIRED_ROLES")),
118
+ enforce_local_active=_as_bool(raw.get("ENFORCE_LOCAL_ACTIVE", False)),
90
119
  )
@@ -7,22 +7,31 @@ plain Django views instead; wire them in via pyobs_auth.urls.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import logging
11
+
10
12
  from django.conf import settings as django_settings
11
13
  from django.contrib.auth import login, logout
12
14
  from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
13
15
  from django.shortcuts import render
14
16
  from django.views import View
15
17
 
18
+ from .authorization import AuthorizationError, authorize
16
19
  from .client import KeycloakClient, TokenExchangeError
17
20
  from .settings import get_settings
18
21
  from .validation import TokenValidationError, TokenValidator
19
22
 
23
+ _logger = logging.getLogger(__name__)
24
+
20
25
  SESSION_STATE_KEY = "pyobs_auth_state"
21
26
  SESSION_CODE_VERIFIER_KEY = "pyobs_auth_code_verifier"
22
27
  SESSION_NEXT_KEY = "pyobs_auth_next"
23
28
  # Presence of this key is also how LogoutView tells "this session came from Keycloak" apart from
24
29
  # a plain local-password session, so it knows whether to also end the Keycloak SSO session.
25
30
  SESSION_ID_TOKEN_KEY = "pyobs_auth_id_token"
31
+ # Both read by KeycloakSessionRefreshMiddleware - absence of either means this session predates
32
+ # the middleware or wasn't established via Keycloak, so it leaves the session alone.
33
+ SESSION_REFRESH_TOKEN_KEY = "pyobs_auth_refresh_token"
34
+ SESSION_ACCESS_EXPIRES_KEY = "pyobs_auth_access_expires"
26
35
 
27
36
 
28
37
  def _error_response(request: HttpRequest, message: str) -> HttpResponse:
@@ -86,11 +95,21 @@ class CallbackView(View):
86
95
  except TokenValidationError as exc:
87
96
  return _error_response(request, f"Received an invalid token: {exc}")
88
97
 
98
+ # Checked before resolving a local user - an unauthorized caller must not mint a User
99
+ # row just by presenting a validly-signed-but-ungrouped token.
100
+ try:
101
+ authorize(claims, settings)
102
+ except AuthorizationError:
103
+ return _error_response(request, "You are not authorized to use this service. Contact an administrator.")
104
+ except ValueError:
105
+ _logger.exception("PYOBS_AUTH['REQUIRED_ROLES'] is malformed")
106
+ raise
107
+
89
108
  user_resolver = settings.resolve_user_callable()
90
109
  user = user_resolver(claims)
91
110
  if user is None:
92
111
  return _error_response(request, "No local user for this token")
93
- if not user.is_active:
112
+ if settings.enforce_local_active and not user.is_active:
94
113
  return _error_response(request, "This account is pending activation. Contact an administrator.")
95
114
 
96
115
  backend = getattr(django_settings, "PYOBS_AUTH_LOGIN_BACKEND", "django.contrib.auth.backends.ModelBackend")
@@ -100,6 +119,28 @@ class CallbackView(View):
100
119
  id_token = tokens.get("id_token")
101
120
  if id_token:
102
121
  request.session[SESSION_ID_TOKEN_KEY] = id_token
122
+ # Kept so KeycloakSessionRefreshMiddleware can silently refresh and re-authorize once the
123
+ # access token expires, instead of a browser session outliving revocation until logout -
124
+ # see pyobs-core's shared-authz-keycloak.md "Revocation model and freshness".
125
+ refresh_token = tokens.get("refresh_token")
126
+ if refresh_token:
127
+ session_engine = getattr(django_settings, "SESSION_ENGINE", "django.contrib.sessions.backends.db")
128
+ if session_engine.endswith(".signed_cookies"):
129
+ # A cookie-backed session serializes into the browser - signed, but not
130
+ # encrypted, so storing a bearer credential that can mint fresh access tokens
131
+ # indefinitely there would hand it to the client. Skip storing it; the session
132
+ # simply won't be refreshable (KeycloakSessionRefreshMiddleware is a no-op for
133
+ # it), same as before this feature existed.
134
+ _logger.warning(
135
+ "SESSION_ENGINE=%s is cookie-backed - not storing the Keycloak refresh token "
136
+ "in the session. KeycloakSessionRefreshMiddleware will be a no-op for this "
137
+ "session; group/role revocation only takes effect at next login. Switch to a "
138
+ "server-side SESSION_ENGINE (db/cached_db) to enable session refresh.",
139
+ session_engine,
140
+ )
141
+ else:
142
+ request.session[SESSION_REFRESH_TOKEN_KEY] = refresh_token
143
+ request.session[SESSION_ACCESS_EXPIRES_KEY] = claims["exp"]
103
144
 
104
145
  return HttpResponseRedirect(next_url)
105
146
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyobs-auth"
3
- version = "2.0.0.dev10"
3
+ version = "2.1.0"
4
4
  description = "Shared Keycloak/OIDC authentication client for pyobs web services"
5
5
  authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }]
6
6
  requires-python = ">=3.11"
File without changes
File without changes