pyobs-auth 2.0.0.dev2__tar.gz → 2.0.0.dev4__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.dev2
3
+ Version: 2.0.0.dev4
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
@@ -112,6 +112,24 @@ class KeycloakClient:
112
112
 
113
113
  return self._post_token(document.token_endpoint, data)
114
114
 
115
+ def end_session_url(self, *, id_token_hint: str, post_logout_redirect_uri: str | None = None) -> str:
116
+ """RP-Initiated Logout: URL to send the browser to end the user's Keycloak SSO session.
117
+
118
+ `id_token_hint` lets Keycloak log the user out with a single redirect instead of showing
119
+ a confirmation page - so an id_token needs to have been kept around from login for this
120
+ to give a clean one-click logout.
121
+ """
122
+ document = self._discovery()
123
+ if not document.end_session_endpoint:
124
+ raise ValueError("this Keycloak realm did not advertise an end_session_endpoint")
125
+
126
+ effective_redirect_uri = post_logout_redirect_uri or self._settings.post_logout_redirect_uri
127
+ params = {"id_token_hint": id_token_hint, "client_id": self._settings.client_id}
128
+ if effective_redirect_uri:
129
+ params["post_logout_redirect_uri"] = effective_redirect_uri
130
+
131
+ return f"{document.end_session_endpoint}?{urlencode(params)}"
132
+
115
133
  def _post_token(self, token_endpoint: str, data: dict[str, str]) -> dict[str, Any]:
116
134
  response = self._session.post(token_endpoint, data=data, timeout=10.0)
117
135
  if response.status_code != 200:
@@ -8,6 +8,7 @@ Read from the Django ``PYOBS_AUTH`` setting, e.g.::
8
8
  "CLIENT_ID": "archive",
9
9
  "CLIENT_SECRET": os.getenv("KEYCLOAK_CLIENT_SECRET"),
10
10
  "REDIRECT_URI": "https://archive.example.org/accounts/keycloak/callback/",
11
+ "POST_LOGOUT_REDIRECT_URI": "https://archive.example.org/",
11
12
  # dotted path to a callable(claims: dict) -> django.contrib.auth.models.User
12
13
  "USER_RESOLVER": "pyobs_archive.authentication.keycloak.resolve_user",
13
14
  }
@@ -29,6 +30,7 @@ class KeycloakSettings:
29
30
  client_secret: str | None = None
30
31
  audience: str | None = None
31
32
  redirect_uri: str | None = None
33
+ post_logout_redirect_uri: str | None = None
32
34
  scopes: tuple[str, ...] = field(default_factory=lambda: ("openid", "profile", "email"))
33
35
  user_resolver: str | None = None
34
36
 
@@ -80,6 +82,7 @@ def get_settings() -> KeycloakSettings:
80
82
  client_secret=raw.get("CLIENT_SECRET"),
81
83
  audience=raw.get("AUDIENCE"),
82
84
  redirect_uri=raw.get("REDIRECT_URI"),
85
+ post_logout_redirect_uri=raw.get("POST_LOGOUT_REDIRECT_URI"),
83
86
  scopes=tuple(raw.get("SCOPES", ("openid", "profile", "email"))),
84
87
  user_resolver=raw.get("USER_RESOLVER"),
85
88
  )
@@ -1,10 +1,11 @@
1
1
  from django.urls import path
2
2
 
3
- from .views import CallbackView, LoginView
3
+ from .views import CallbackView, LoginView, LogoutView
4
4
 
5
5
  app_name = "pyobs_auth"
6
6
 
7
7
  urlpatterns = [
8
8
  path("login/", LoginView.as_view(), name="login"),
9
9
  path("callback/", CallbackView.as_view(), name="callback"),
10
+ path("logout/", LogoutView.as_view(), name="logout"),
10
11
  ]
@@ -8,7 +8,7 @@ plain Django views instead; wire them in via pyobs_auth.urls.
8
8
  from __future__ import annotations
9
9
 
10
10
  from django.conf import settings as django_settings
11
- from django.contrib.auth import login
11
+ from django.contrib.auth import login, logout
12
12
  from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
13
13
  from django.views import View
14
14
 
@@ -19,6 +19,9 @@ from .validation import TokenValidationError, TokenValidator
19
19
  SESSION_STATE_KEY = "pyobs_auth_state"
20
20
  SESSION_CODE_VERIFIER_KEY = "pyobs_auth_code_verifier"
21
21
  SESSION_NEXT_KEY = "pyobs_auth_next"
22
+ # Presence of this key is also how LogoutView tells "this session came from Keycloak" apart from
23
+ # a plain local-password session, so it knows whether to also end the Keycloak SSO session.
24
+ SESSION_ID_TOKEN_KEY = "pyobs_auth_id_token"
22
25
 
23
26
 
24
27
  class LoginView(View):
@@ -29,7 +32,9 @@ class LoginView(View):
29
32
 
30
33
  request.session[SESSION_STATE_KEY] = authorization.state
31
34
  request.session[SESSION_CODE_VERIFIER_KEY] = authorization.code_verifier
32
- request.session[SESSION_NEXT_KEY] = request.GET.get("next", "/")
35
+ # `or "/"` (not just a dict default) because `?next=` with an empty value is a present-but-
36
+ # falsy key - `.get("next", "/")` alone would return "" instead of falling back to "/".
37
+ request.session[SESSION_NEXT_KEY] = request.GET.get("next") or "/"
33
38
 
34
39
  return HttpResponseRedirect(authorization.url)
35
40
 
@@ -44,7 +49,7 @@ class CallbackView(View):
44
49
  state = request.GET.get("state")
45
50
  expected_state = request.session.pop(SESSION_STATE_KEY, None)
46
51
  code_verifier = request.session.pop(SESSION_CODE_VERIFIER_KEY, None)
47
- next_url = request.session.pop(SESSION_NEXT_KEY, "/")
52
+ next_url = request.session.pop(SESSION_NEXT_KEY, "/") or "/"
48
53
 
49
54
  if not code or not state or not code_verifier or state != expected_state:
50
55
  return HttpResponseBadRequest("Invalid or expired login state")
@@ -74,5 +79,34 @@ class CallbackView(View):
74
79
 
75
80
  backend = getattr(django_settings, "PYOBS_AUTH_LOGIN_BACKEND", "django.contrib.auth.backends.ModelBackend")
76
81
  login(request, user, backend=backend)
82
+ # Set after login(), which rotates the session key - setting it before would risk the
83
+ # value getting lost if that rotation ever stopped preserving existing session data.
84
+ id_token = tokens.get("id_token")
85
+ if id_token:
86
+ request.session[SESSION_ID_TOKEN_KEY] = id_token
77
87
 
78
88
  return HttpResponseRedirect(next_url)
89
+
90
+
91
+ class LogoutView(View):
92
+ """Ends the local Django session and, only if this session was established via Keycloak
93
+ (an id_token was stored at login), also ends the Keycloak SSO session via RP-Initiated
94
+ Logout - so a plain local-password session just gets an ordinary local logout, unaffected."""
95
+
96
+ http_method_names = ["post"]
97
+
98
+ def post(self, request: HttpRequest) -> HttpResponse:
99
+ id_token = request.session.pop(SESSION_ID_TOKEN_KEY, None)
100
+ logout(request)
101
+
102
+ if id_token is None:
103
+ next_url = request.POST.get("next") or request.GET.get("next") or "/"
104
+ return HttpResponseRedirect(next_url)
105
+
106
+ # post_logout_redirect_uri here deliberately comes only from PYOBS_AUTH (a fixed,
107
+ # pre-registered absolute URL, like REDIRECT_URI for login) rather than from a per-request
108
+ # `next` - Keycloak validates it against "Valid post logout redirect URIs" for the client,
109
+ # so an arbitrary relative path wouldn't match and the redirect would fail.
110
+ settings = get_settings()
111
+ client = KeycloakClient(settings)
112
+ return HttpResponseRedirect(client.end_session_url(id_token_hint=id_token))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyobs-auth"
3
- version = "2.0.0.dev2"
3
+ version = "2.0.0.dev4"
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