Add account recovery, visible access records and shared sign-out handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 1m21s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-12 10:34:44 +02:00
parent 7330c25d80
commit e54b6ee970
5 changed files with 169 additions and 13 deletions

View file

@ -89,7 +89,11 @@ class OIDCClient:
with urlopen(request, timeout=10) as response:
tokens = json.loads(response.read())
token = str(tokens.get("id_token") or tokens.get("access_token") or "")
claims = self._verify(token)
import jwt
try:
claims = self._verify(token)
except jwt.PyJWTError:
raise ValueError("OIDC token verification failed") from None
session_id = secrets.token_urlsafe(32)
expiry = min(float(claims.get("exp", time.time() + self.session_ttl)), time.time() + self.session_ttl)
self.sessions[session_id] = BrowserSession(

View file

@ -22,6 +22,7 @@ from collections import deque
from threading import Lock
from time import monotonic
from typing import Any, Callable, Iterable, Mapping
from urllib.error import URLError
from urllib.parse import parse_qs, quote, unquote, urlencode, urlsplit
from user_engine.domain import (
@ -175,12 +176,16 @@ class PortalApplication:
if self.oidc_client is None:
raise NotFoundError("OIDC login is not configured")
query = parse_qs(str(environ.get("QUERY_STRING", "")))
if query.get("error"):
raise AuthorizationDenied("OIDC login failed")
session_id = self.oidc_client.complete(
code=query.get("code", [""])[0],
state=query.get("state", [""])[0],
)
try:
if query.get("error"):
self.oidc_client.pending.pop(query.get("state", [""])[0], None)
raise ValueError("OIDC login failed")
session_id = self.oidc_client.complete(
code=query.get("code", [""])[0],
state=query.get("state", [""])[0],
)
except (ValueError, URLError, OSError):
return self._redirect(start_response, "/access-recovery", correlation_id)
headers = [
("Location", "/"),
("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"),
@ -188,6 +193,24 @@ class PortalApplication:
]
start_response("303 See Other", headers)
return [b""]
if path == "/access-recovery" and method == "GET":
try:
actor = self._optional_actor(environ)
except AuthorizationDenied:
actor = None
self._set_account_navigation(environ, actor)
identity = (
f'<p>This portal is signed in as <strong>{escape(actor.preferred_username or actor.subject)}</strong>.</p>'
'<p><a class="button" href="/onboarding">View my account and access</a></p>'
if actor else '<p>Your identity has not been verified in this portal.</p>'
)
return self._html(start_response, self._page_html(
"Sign-in help", '<h1>Sign-in could not be completed</h1>'
'<p>The application may not allow this account, or the sign-in service may have failed.</p>'
+ identity
+ '<p><a href="/login">Verify my current identity</a></p>'
'<p><a href="/logout">Log out or use another account</a></p>',
), correlation_id)
if path == "/logged-out" and method == "GET":
if self._optional_actor(environ) is not None:
return self._redirect(start_response, "/", correlation_id)
@ -195,8 +218,8 @@ class PortalApplication:
"Logged out",
'<h1>You have logged out.</h1>'
'<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>'
'<p>To sign in as another account while keeping that session, open this portal in a private browser window.</p>'
'<p><a class="button" href="/login">Sign in</a></p>',
+ self._shared_logout_link()
+ '<p><a class="button" href="/login">Sign in</a></p>',
), correlation_id)
if path == "/logout" and method == "GET":
actor = self._optional_actor(environ)
@ -209,17 +232,18 @@ class PortalApplication:
'<p>This ends your portal session. Your shared NetKingdom sign-in stays active.</p>'
'<form method="post" action="/logout">'
f'<input type="hidden" name="csrf_token" value="{escape(token)}">'
'<button type="submit">Log out</button></form>',
'<button type="submit">Log out of this portal</button>'
'<button type="submit" name="scope" value="shared">Continue to NetKingdom sign-out</button></form>',
), correlation_id)
if path == "/logout" and method == "POST":
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
body = self._form_body(environ)
if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None:
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", "")))
self.oidc_client.logout(session_id)
start_response(
"303 See Other",
[("Location", "/logged-out"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
[("Location", self.oidc_client.issuer + "/account/logout" if body.get("scope") == "shared" and self.oidc_client else "/logged-out"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
)
return [b""]
if path == "/" and method == "GET":
@ -1520,6 +1544,14 @@ class PortalApplication:
raise ValidationError("Idempotency-Key must contain at least 16 characters")
return value
def _shared_logout_link(self) -> str:
if not self.oidc_client:
return ""
return (
f'<p><a class="button" href="{escape(self.oidc_client.issuer)}/account/logout">'
'Sign out of NetKingdom to use another account</a></p>'
)
def _home(self, actor: Any | None) -> str:
identity = (
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
@ -1810,7 +1842,7 @@ class PortalApplication:
return self._page_html(
"Onboarding",
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1>
<section aria-labelledby="verification"><h2 id="verification">Email and sign-in</h2><p>{escape(verification)}</p><p>Passwords and MFA are managed by your identity provider.</p></section>
<section aria-labelledby="verification"><h2 id="verification">Current identity</h2><p>Signed in as <strong>{escape(session.actor.preferred_username or session.actor.subject)}</strong>.</p><p>Sign-in tenant: {escape(session.actor.tenant)}.</p><p>Roles: {escape(", ".join(session.actor.roles) or "None")}.</p><p>{escape(verification)}</p><p>Passwords and MFA are managed by your identity provider.</p></section>
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
@ -1818,9 +1850,18 @@ class PortalApplication:
<button type="submit">Save profile</button></form></section>
<section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul>
<p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section>
<section aria-labelledby="workloads"><h2 id="workloads">Workload access</h2>{self._workload_memberships(memberships)}<p>Each application checks access when you open it. Tenant membership alone does not grant access to every application.</p></section>
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
)
@staticmethod
def _workload_memberships(memberships: tuple[Any, ...]) -> str:
items = "".join(
f"<li>{escape(item.scope_id)}{escape(item.kind)} ({escape(item.tenant)})</li>"
for item in memberships if item.scope_type in {"application", "service", "workload", "asset"}
)
return "<ul>" + items + "</ul>" if items else "<p>No workload-specific access is recorded for this account.</p>"
@staticmethod
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
steps = "".join(