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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyobs-auth
3
- Version: 2.0.0.dev6
3
+ Version: 2.0.0.dev9
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
@@ -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(self, *, redirect_uri: str | None = None) -> AuthorizationRequest:
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, HttpResponseBadRequest, HttpResponseRedirect
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
- authorization = client.start_authorization()
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 HttpResponseBadRequest(f"Keycloak login failed: {error}")
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 HttpResponseBadRequest("Invalid or expired login state")
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 HttpResponseBadRequest(f"Token exchange failed: {exc}")
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 HttpResponseBadRequest("No access_token in Keycloak's response")
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 HttpResponseBadRequest(f"Received an invalid token: {exc}")
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 HttpResponseBadRequest("No local user for this token")
92
+ return _error_response(request, "No local user for this token")
79
93
  if not user.is_active:
80
- return HttpResponseBadRequest("Account pending activation")
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.dev6"
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