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.
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/PKG-INFO +1 -1
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/client.py +18 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/settings.py +3 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/urls.py +2 -1
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/views.py +37 -3
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyproject.toml +1 -1
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/.gitignore +0 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/LICENSE +0 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/__init__.py +0 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/apps.py +0 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/authentication.py +0 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/discovery.py +0 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/py.typed +0 -0
- {pyobs_auth-2.0.0.dev2 → pyobs_auth-2.0.0.dev4}/pyobs_auth/validation.py +0 -0
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|