pyobs-auth 2.0.0.dev6__tar.gz → 2.0.0.dev9__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.dev6 → pyobs_auth-2.0.0.dev9}/PKG-INFO +1 -1
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/client.py +7 -1
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/settings.py +2 -0
- pyobs_auth-2.0.0.dev9/pyobs_auth/templates/pyobs_auth/error.html +50 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/views.py +23 -9
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyproject.toml +1 -1
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/.gitignore +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/LICENSE +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/__init__.py +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/apps.py +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/authentication.py +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/discovery.py +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/py.typed +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/urls.py +0 -0
- {pyobs_auth-2.0.0.dev6 → pyobs_auth-2.0.0.dev9}/pyobs_auth/validation.py +0 -0
|
@@ -42,7 +42,9 @@ class KeycloakClient:
|
|
|
42
42
|
def _discovery(self):
|
|
43
43
|
return fetch_discovery_document(self._settings.discovery_url)
|
|
44
44
|
|
|
45
|
-
def start_authorization(
|
|
45
|
+
def start_authorization(
|
|
46
|
+
self, *, idp_hint: str | None = None, redirect_uri: str | None = None
|
|
47
|
+
) -> AuthorizationRequest:
|
|
46
48
|
"""Build the redirect URL for the authorization-code + PKCE login flow."""
|
|
47
49
|
document = self._discovery()
|
|
48
50
|
state = _b64url(secrets.token_bytes(24))
|
|
@@ -62,6 +64,10 @@ class KeycloakClient:
|
|
|
62
64
|
"code_challenge": code_challenge,
|
|
63
65
|
"code_challenge_method": "S256",
|
|
64
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
|
|
65
71
|
url = f"{document.authorization_endpoint}?{urlencode(params)}"
|
|
66
72
|
return AuthorizationRequest(url=url, state=state, code_verifier=code_verifier)
|
|
67
73
|
|
|
@@ -33,6 +33,7 @@ class KeycloakSettings:
|
|
|
33
33
|
post_logout_redirect_uri: str | None = None
|
|
34
34
|
scopes: tuple[str, ...] = field(default_factory=lambda: ("openid", "profile", "email"))
|
|
35
35
|
user_resolver: str | None = None
|
|
36
|
+
idp_hint: str | None = None
|
|
36
37
|
|
|
37
38
|
@property
|
|
38
39
|
def issuer(self) -> str:
|
|
@@ -85,4 +86,5 @@ def get_settings() -> KeycloakSettings:
|
|
|
85
86
|
post_logout_redirect_uri=raw.get("POST_LOGOUT_REDIRECT_URI"),
|
|
86
87
|
scopes=tuple(raw.get("SCOPES", ("openid", "profile", "email"))),
|
|
87
88
|
user_resolver=raw.get("USER_RESOLVER"),
|
|
89
|
+
idp_hint=raw.get("IDP_HINT"),
|
|
88
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>
|
|
@@ -9,7 +9,8 @@ from __future__ import annotations
|
|
|
9
9
|
|
|
10
10
|
from django.conf import settings as django_settings
|
|
11
11
|
from django.contrib.auth import login, logout
|
|
12
|
-
from django.http import HttpRequest, HttpResponse,
|
|
12
|
+
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|
13
|
+
from django.shortcuts import render
|
|
13
14
|
from django.views import View
|
|
14
15
|
|
|
15
16
|
from .client import KeycloakClient, TokenExchangeError
|
|
@@ -24,11 +25,24 @@ SESSION_NEXT_KEY = "pyobs_auth_next"
|
|
|
24
25
|
SESSION_ID_TOKEN_KEY = "pyobs_auth_id_token"
|
|
25
26
|
|
|
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
|
+
|
|
27
36
|
class LoginView(View):
|
|
28
37
|
def get(self, request: HttpRequest) -> HttpResponse:
|
|
29
38
|
settings = get_settings()
|
|
30
39
|
client = KeycloakClient(settings)
|
|
31
|
-
|
|
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)
|
|
32
46
|
|
|
33
47
|
request.session[SESSION_STATE_KEY] = authorization.state
|
|
34
48
|
request.session[SESSION_CODE_VERIFIER_KEY] = authorization.code_verifier
|
|
@@ -43,7 +57,7 @@ class CallbackView(View):
|
|
|
43
57
|
def get(self, request: HttpRequest) -> HttpResponse:
|
|
44
58
|
error = request.GET.get("error")
|
|
45
59
|
if error:
|
|
46
|
-
return
|
|
60
|
+
return _error_response(request, f"Keycloak login failed: {error}")
|
|
47
61
|
|
|
48
62
|
code = request.GET.get("code")
|
|
49
63
|
state = request.GET.get("state")
|
|
@@ -52,7 +66,7 @@ class CallbackView(View):
|
|
|
52
66
|
next_url = request.session.pop(SESSION_NEXT_KEY, "/") or "/"
|
|
53
67
|
|
|
54
68
|
if not code or not state or not code_verifier or state != expected_state:
|
|
55
|
-
return
|
|
69
|
+
return _error_response(request, "Invalid or expired login state")
|
|
56
70
|
|
|
57
71
|
settings = get_settings()
|
|
58
72
|
client = KeycloakClient(settings)
|
|
@@ -60,24 +74,24 @@ class CallbackView(View):
|
|
|
60
74
|
try:
|
|
61
75
|
tokens = client.exchange_code(code=code, code_verifier=code_verifier)
|
|
62
76
|
except TokenExchangeError as exc:
|
|
63
|
-
return
|
|
77
|
+
return _error_response(request, f"Token exchange failed: {exc}")
|
|
64
78
|
|
|
65
79
|
access_token = tokens.get("access_token")
|
|
66
80
|
if not access_token:
|
|
67
|
-
return
|
|
81
|
+
return _error_response(request, "No access_token in Keycloak's response")
|
|
68
82
|
|
|
69
83
|
validator = TokenValidator(settings)
|
|
70
84
|
try:
|
|
71
85
|
claims = validator.validate(access_token)
|
|
72
86
|
except TokenValidationError as exc:
|
|
73
|
-
return
|
|
87
|
+
return _error_response(request, f"Received an invalid token: {exc}")
|
|
74
88
|
|
|
75
89
|
user_resolver = settings.resolve_user_callable()
|
|
76
90
|
user = user_resolver(claims)
|
|
77
91
|
if user is None:
|
|
78
|
-
return
|
|
92
|
+
return _error_response(request, "No local user for this token")
|
|
79
93
|
if not user.is_active:
|
|
80
|
-
return
|
|
94
|
+
return _error_response(request, "This account is pending activation. Contact an administrator.")
|
|
81
95
|
|
|
82
96
|
backend = getattr(django_settings, "PYOBS_AUTH_LOGIN_BACKEND", "django.contrib.auth.backends.ModelBackend")
|
|
83
97
|
login(request, user, backend=backend)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyobs-auth"
|
|
3
|
-
version = "2.0.0.
|
|
3
|
+
version = "2.0.0.dev9"
|
|
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
|
|
File without changes
|