Show an existing NetKingdom sign-in before the account site continues it.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 3s
Build and Publish Container Image / build-and-push (push) Successful in 19s
Account journey acceptance / journeys (push) Successful in 8s

The account site said "Not signed in" while Authelia still had a session, and Sign in reused that identity. Ask Authelia who the session cookie is, show that name, and send a fresh sign-in only when a different identity is requested.

Assistant: grok
Assistant-Session: 01a0d25d-d358-7e13-b84a-d007fbb7e34f
This commit is contained in:
tegwick 2026-09-27 00:21:34 +02:00
parent b987a3de9e
commit 560cdeed46
9 changed files with 500 additions and 19 deletions

View file

@ -35,6 +35,7 @@ from user_engine.domain import (
PrincipalType,
)
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
from user_engine.identity_state import is_identity_name
from user_engine.oidc import OIDCClient, cookie_value
from user_engine.ports import (
IdentityProvisioningPort,
@ -50,6 +51,7 @@ StartResponse = Callable[[str, list[tuple[str, str]]], Any]
# Rendering state is scoped to one request, including concurrent WSGI requests.
_ACCOUNT_NAVIGATION: ContextVar[str] = ContextVar("account_navigation", default="")
_BROWSER_REQUEST: ContextVar[bool] = ContextVar("browser_request", default=False)
_REQUEST_CACHE: ContextVar[dict[str, Any] | None] = ContextVar("portal_request_cache", default=None)
def _jsonable(value: Any) -> Any:
@ -89,6 +91,7 @@ class PortalApplication:
registration_password_setup_origins: tuple[str, ...] = (),
registration_rate_limit: int = 10,
registration_rate_window_seconds: int = 60,
identity_lookup: Any | None = None,
) -> None:
if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters")
@ -124,10 +127,12 @@ class PortalApplication:
self.registration_rate_window_seconds = registration_rate_window_seconds
self._registration_attempts: dict[str, deque[float]] = {}
self._registration_attempts_lock = Lock()
self.identity_lookup = identity_lookup
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
navigation_token = _ACCOUNT_NAVIGATION.set("")
cache_token = _REQUEST_CACHE.set({})
browser_token = _BROWSER_REQUEST.set(
"text/html" in str(environ.get("HTTP_ACCEPT", ""))
and not str(environ.get("PATH_INFO", "/")).startswith("/api/")
@ -158,6 +163,7 @@ class PortalApplication:
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
finally:
_ACCOUNT_NAVIGATION.reset(navigation_token)
_REQUEST_CACHE.reset(cache_token)
_BROWSER_REQUEST.reset(browser_token)
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
@ -192,8 +198,21 @@ class PortalApplication:
tenant_hint = query.get("tenant_hint", [None])[0]
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
raise ValidationError("tenant_hint must be a tenant identifier")
recovery = query.get("recovery") == ["1"]
fresh = query.get("fresh") == ["1"] and not recovery
begin_arguments: dict[str, Any] = {}
if recovery:
begin_arguments = {
"recovery": True,
"return_path": query.get("return_path", ["/platform/factor-recovery"])[0],
}
elif fresh:
begin_arguments = {"fresh": True}
location = (
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None, **({"recovery": True, "return_path": query.get("return_path", ["/platform/factor-recovery"])[0]} if query.get("recovery") == ["1"] else {}))
self.oidc_client.begin(
tenant_hint=str(tenant_hint) if tenant_hint else None,
**begin_arguments,
)
if self.oidc_client else self.login_url
)
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
@ -225,11 +244,18 @@ class PortalApplication:
except AuthorizationDenied:
actor = None
self._set_account_navigation(environ, actor)
identity = (
f'<p>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>You are not signed in. Open the account site with your NetKingdom identity.</p>'
)
if actor is not None:
identity = (
f'<p>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>'
)
else:
pending = self._pending_identity_copy(self._netkingdom_name())
identity = (
pending
if pending
else '<p>You are not signed in. Open the account site with your NetKingdom identity.</p>'
)
return self._html(start_response, self._page_html(
"Sign-in help", '<h1>Sign-in could not be completed</h1>'
'<p>Your account may not have access to the application, or sign-in may have been interrupted.</p>'
@ -242,11 +268,23 @@ class PortalApplication:
if path == "/logged-out" and method == "GET":
if self._optional_actor(environ) is not None:
return self._redirect(start_response, "/", correlation_id)
pending = self._pending_identity_copy(self._netkingdom_name())
if pending:
title = "NetKingdom sign-in"
signed_out = (
"<h1>This account site has no session yet.</h1>"
+ pending
)
else:
title = "Not signed in"
signed_out = (
'<h1>You are not signed in.</h1>'
'<p>Your shared NetKingdom sign-in may still be active. '
'Opening the account site again may reuse that identity.</p>'
)
return self._html(start_response, self._page_html(
"Not signed in",
'<h1>You are not signed in.</h1>'
'<p>Your shared NetKingdom sign-in may still be active. Opening the account site again may reuse that identity.</p>'
+ self._identity_switch_help(),
title,
signed_out + self._identity_switch_help(),
), correlation_id)
if path == "/logout" and method == "GET":
actor = self._optional_actor(environ)
@ -2195,13 +2233,18 @@ Use the login name they provide; it may differ from your display name.</p></sect
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
)
@staticmethod
def _login_state(actor: Any | None) -> str:
def _login_state(self, actor: Any | None) -> str:
level = (
"<p>A one-time code raises the security level of a NetKingdom sign-in. "
"It is not another sign-in.</p>"
)
if actor is None:
pending = self._pending_identity_copy(self._netkingdom_name())
if pending:
return (
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
f"{pending}{level}</section>"
)
return (
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
"<p>You are not signed in.</p>"
@ -2376,8 +2419,48 @@ Use the login name they provide; it may differ from your display name.</p></sect
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
return [b""]
def _netkingdom_name(self, environ: Mapping[str, Any] | None = None) -> str | None:
cache = _REQUEST_CACHE.get()
if cache is None:
return None
if "netkingdom_name" in cache:
return cache["netkingdom_name"]
name: str | None = None
if environ is not None and self.identity_lookup is not None:
try:
found = self.identity_lookup.username(str(environ.get("HTTP_COOKIE", "")))
except Exception:
found = None
if is_identity_name(found):
name = found
cache["netkingdom_name"] = name
return name
@staticmethod
def _pending_identity_copy(name: str | None) -> str:
if not name:
return ""
safe = escape(name)
return (
f"<p>NetKingdom sign-in is <strong>{safe}</strong>.</p>"
"<p>This account site has no session yet.</p>"
f'<p><a class="button" href="/login">Continue as {safe}</a> '
'<a href="/login?fresh=1">Use a different identity</a></p>'
)
def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None:
if actor is None:
name = self._netkingdom_name(environ)
if name:
safe = escape(name)
_ACCOUNT_NAVIGATION.set(
f'<p>NetKingdom sign-in is <strong>{safe}</strong>. This account site has no session yet.</p>'
'<nav aria-label="Account navigation"><a href="/">Home</a>'
'<a href="/security">Sign-in help</a>'
f'<a class="button" href="/login">Continue as {safe}</a>'
'<a href="/login?fresh=1">Use a different identity</a></nav>'
)
return
_ACCOUNT_NAVIGATION.set('<p>Not signed in</p><nav aria-label="Account navigation"><a href="/">Home</a><a href="/security">Sign-in help</a><a class="button" href="/login">Sign in</a></nav>')
return
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'