From 560cdeed4620da8e4b41014c0989cb7e5352e4b9 Mon Sep 17 00:00:00 2001
From: tegwick
Date: Sun, 27 Sep 2026 00:21:34 +0200
Subject: [PATCH] Show an existing NetKingdom sign-in before the account site
continues it.
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
---
docs/account-journeys.md | 18 ++-
layer.yaml | 8 ++
src/user_engine/identity_state.py | 114 +++++++++++++++++
src/user_engine/oidc.py | 13 +-
src/user_engine/runtime.py | 6 +
src/user_engine/web.py | 107 ++++++++++++++--
tests/test_account_identity_disclosure.py | 106 ++++++++++++++++
tests/test_identity_state.py | 141 ++++++++++++++++++++++
tests/test_oidc.py | 6 +
9 files changed, 500 insertions(+), 19 deletions(-)
create mode 100644 src/user_engine/identity_state.py
create mode 100644 tests/test_account_identity_disclosure.py
create mode 100644 tests/test_identity_state.py
diff --git a/docs/account-journeys.md b/docs/account-journeys.md
index d7d3950..b1f5e29 100644
--- a/docs/account-journeys.md
+++ b/docs/account-journeys.md
@@ -9,11 +9,17 @@ headless capability alone does not mean a journey is usable or verified live.
## Common interaction rules
-- The header is titled NetKingdom Identity. It states “Signed in as” the
- verified identity, or “Not signed in.” A valid account-site session shows
- Log out; an absent or expired session shows Sign in. A one-time code raises
- the security level of the NetKingdom sign-in and is not another sign-in.
- Never infer identity from URL parameters or an existing provider tab.
+- The header is titled NetKingdom Identity. An account-site session says
+ “Signed in as” the verified identity and offers Log out. With no account-site
+ session, the header says “Not signed in” and offers Sign in. When the browser
+ already sent a NetKingdom session cookie, the account site asks the sign-in
+ service which identity that cookie is and shows “NetKingdom sign-in is” that
+ confirmed name, with “This account site has no session yet.” Continue reuses
+ that identity. “Use a different identity” starts a fresh NetKingdom sign-in.
+ A URL parameter does not name the visitor. If the sign-in service does not
+ answer, the page stays “Not signed in” and does not invent a name. A one-time
+ code raises the security level of the NetKingdom sign-in and is not another
+ sign-in.
- The portal cannot observe every application or shared-provider session. Explain
this once in sign-out confirmation or expandable identity-switch help, not as
competing login/logout actions everywhere. “Use another account” remains
@@ -36,7 +42,7 @@ headless capability alone does not mean a journey is usable or verified live.
| ID / intent | Success | Failure and recovery | Current support / acceptance |
|---|---|---|---|
-| U01 — Know whether I am signed in | Header and the home page say “Signed in as” the verified identity, or that no account-site session exists. An application may keep its own session. A one-time code is a higher security level, not another sign-in | Expired/unknown cookie shows signed-out state; a query does not invent a session; sign in again | Implemented; automated anonymous/expired/member/operator tests, including the home identity section |
+| U01 — Know whether I am signed in | Header and the home page say “Signed in as” the verified identity when an account-site session exists. With no account-site session they say “Not signed in,” unless the sign-in service confirms an existing NetKingdom identity, which is then named before the account site continues. An application may keep its own session. A one-time code is a higher security level, not another sign-in | Expired/unknown cookie shows signed-out state; a query does not invent a session; a failed identity lookup stays signed out; sign in again, or use a different identity | Implemented; automated anonymous/expired/member/operator tests, including the home identity section and a confirmed NetKingdom sign-in with no account-site session |
| U02 — Sign in to my company application | Personal login lands in the intended tenant and application | Wrong credentials stay on provider; denied membership leads to account help with identity switching | Recovery deployed previously; actual fresh-user acceptance waiting on OTP |
| U03 — Accept an invitation | Confirm intended tenant/role, accept once, then see next setup step | Expired/used/wrong-person invitation explains next step; admin reissues without duplicates | Service/browser routes exist; live delivery and full browser acceptance pending |
| U04 — Set or recover my password | Use actual login name, complete single-use setup, return to sign-in | Missing mail or expired link offers admin-assisted new setup link | Password setup reported successful; login name and sign-in address now named at handoff and in the user entry (2026-09-23 run, USER-WP-0035-T01); email delivery unresolved (USER-WP-0035-T02) |
diff --git a/layer.yaml b/layer.yaml
index 9fbdbe3..f652b8e 100644
--- a/layer.yaml
+++ b/layer.yaml
@@ -72,6 +72,14 @@ non_tooling_clients:
write: false
note: Consumes identity claims as PIP input. Not a key-cape admin client.
+ - id: netkingdom-sign-in-state
+ target: key-cape
+ layer: tooling-as-claim-input
+ module: src/user_engine/identity_state.py
+ operation: "GET Authelia /api/state for the username on the session cookie the browser already sent"
+ write: false
+ note: Names an existing NetKingdom sign-in. Does not create an account-site session and is not an authorization decision.
+
- id: env-injected-secrets
target: Railiance secret injection
layer: not-catalogued
diff --git a/src/user_engine/identity_state.py b/src/user_engine/identity_state.py
new file mode 100644
index 0000000..eff0096
--- /dev/null
+++ b/src/user_engine/identity_state.py
@@ -0,0 +1,114 @@
+"""Read an existing NetKingdom sign-in without creating an account-site session.
+
+The account site receives the Authelia session cookie because that cookie is
+set for the parent domain. This module asks Authelia who that cookie is and
+returns only a confirmed username. It does not store the cookie, follow
+redirects, or treat the answer as an account-site session.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlsplit
+from urllib.request import HTTPRedirectHandler, Request, build_opener
+
+from user_engine.oidc import cookie_value
+
+_IDENTITY_NAME = re.compile(r"^[A-Za-z0-9._@+-]{1,200}$")
+_SESSION_TOKEN = re.compile(r"^[A-Za-z0-9._~+/=-]{1,4096}$")
+_CLUSTER_HOST = ".svc.cluster.local"
+
+
+def is_identity_name(value: object) -> bool:
+ return isinstance(value, str) and _IDENTITY_NAME.fullmatch(value) is not None
+
+
+def validate_identity_state_url(url: str) -> str:
+ """Accept the fixed Authelia state endpoint and reject anything else."""
+ if any(character.isspace() for character in url):
+ raise ValueError("identity state URL must not contain whitespace")
+ parts = urlsplit(url)
+ host = parts.hostname or ""
+ # Hostname matching is on the DNS label boundary. A name that only
+ # contains the suffix, such as "svc.cluster.local.example", does not end
+ # with ".svc.cluster.local".
+ cluster = host.endswith(_CLUSTER_HOST)
+ allowed = (parts.scheme == "https" and bool(host)) or (parts.scheme == "http" and cluster)
+ if (
+ not allowed
+ or parts.username
+ or parts.password
+ or parts.query
+ or parts.fragment
+ or parts.path != "/api/state"
+ ):
+ raise ValueError(
+ "identity state URL must be https://host/api/state "
+ "or http://name.svc.cluster.local/api/state"
+ )
+ return url
+
+
+def authelia_session_token(cookie_header: str) -> str | None:
+ value = cookie_value(cookie_header, "authelia_session")
+ if value is None or _SESSION_TOKEN.fullmatch(value) is None:
+ return None
+ return value
+
+
+def username_from_state(payload: object) -> str | None:
+ if not isinstance(payload, dict):
+ return None
+ if "status" in payload and payload.get("status") != "OK":
+ return None
+ data = payload.get("data")
+ if not isinstance(data, dict):
+ data = payload
+ username = data.get("username")
+ level = data.get("authentication_level")
+ if type(level) is not int or level < 1 or not is_identity_name(username):
+ return None
+ return username
+
+
+class _RefuseRedirects(HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001
+ raise URLError("identity state endpoint must not redirect")
+
+
+def _read_state(url: str, token: str, timeout: float) -> bytes:
+ request = Request(
+ url,
+ headers={
+ "Accept": "application/json",
+ "Cookie": f"authelia_session={token}",
+ },
+ method="GET",
+ )
+ opener = build_opener(_RefuseRedirects)
+ with opener.open(request, timeout=timeout) as response:
+ return response.read(8192)
+
+
+class AutheliaIdentityState:
+ """Confirm the username on one Authelia session cookie."""
+
+ def __init__(self, state_url: str, *, timeout: float = 2.0, reader=_read_state) -> None:
+ if timeout <= 0:
+ raise ValueError("identity state timeout must be positive")
+ self.state_url = validate_identity_state_url(state_url)
+ self.timeout = timeout
+ self._reader = reader
+
+ def username(self, cookie_header: str) -> str | None:
+ token = authelia_session_token(cookie_header)
+ if token is None:
+ return None
+ try:
+ raw = self._reader(self.state_url, token, self.timeout)
+ payload = json.loads(raw.decode("utf-8"))
+ except (HTTPError, URLError, TimeoutError, OSError, UnicodeError, json.JSONDecodeError, ValueError):
+ return None
+ return username_from_state(payload)
diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py
index c8e8b99..58181e7 100644
--- a/src/user_engine/oidc.py
+++ b/src/user_engine/oidc.py
@@ -53,7 +53,14 @@ class OIDCClient:
self.pending: dict[str, PendingLogin] = {}
self.sessions: dict[str, BrowserSession] = {}
- def begin(self, *, tenant_hint: str | None = None, recovery: bool = False, return_path: str = "/") -> str:
+ def begin(
+ self,
+ *,
+ tenant_hint: str | None = None,
+ recovery: bool = False,
+ fresh: bool = False,
+ return_path: str = "/",
+ ) -> str:
if return_path not in {"/", "/platform", "/platform/factor-recovery", "/platform/authentication-policy"}:
raise ValueError("unsupported login return path")
state = secrets.token_urlsafe(32)
@@ -72,6 +79,10 @@ class OIDCClient:
}
if recovery:
parameters.update(prompt="login", max_age="0", acr_values="aal2")
+ elif fresh:
+ # KeyCape asks Authelia for a session inside its short fresh window
+ # and then requires that authentication to be newer than this request.
+ parameters.update(prompt="login", max_age="0")
if tenant_hint:
parameters["tenant_hint"] = tenant_hint
return f"{self.issuer}/authorize?{urlencode(parameters)}"
diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py
index 2c15d93..33129cb 100644
--- a/src/user_engine/runtime.py
+++ b/src/user_engine/runtime.py
@@ -15,6 +15,7 @@ from user_engine.adapters import (
HTTPTenantManagementAdapter,
)
from user_engine.service import UserEngineService
+from user_engine.identity_state import AutheliaIdentityState
from user_engine.oidc import OIDCClient
from user_engine.web import PortalApplication
from user_engine.factor_recovery import FactorRecoveryClient
@@ -118,6 +119,11 @@ def create_application() -> PortalApplication:
registration_rate_window_seconds=int(
os.environ.get("USER_ENGINE_REGISTRATION_RATE_WINDOW_SECONDS", "60")
),
+ identity_lookup=(
+ AutheliaIdentityState(os.environ["USER_ENGINE_IDENTITY_STATE_URL"])
+ if os.environ.get("USER_ENGINE_IDENTITY_STATE_URL")
+ else None
+ ),
)
from user_engine.operations_status import check_services
diff --git a/src/user_engine/web.py b/src/user_engine/web.py
index 2fe5ede..59765bc 100644
--- a/src/user_engine/web.py
+++ b/src/user_engine/web.py
@@ -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'
Signed in as {escape(actor.preferred_username or actor.subject)}.
Your shared NetKingdom sign-in may still be active. Opening the account site again may reuse that identity.
'
- + 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.
A one-time code raises the security level of a NetKingdom sign-in. "
"It is not another sign-in.
"
)
if actor is None:
+ pending = self._pending_identity_copy(self._netkingdom_name())
+ if pending:
+ return (
+ '
Identity
'
+ f"{pending}{level}"
+ )
return (
'
Identity
'
"
You are not signed in.
"
@@ -2376,8 +2419,48 @@ Use the login name they provide; it may differ from your display name. 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"
'
+ )
+
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'
NetKingdom sign-in is {safe}. This account site has no session yet.
'
+ ''
+ )
+ return
_ACCOUNT_NAVIGATION.set('
Not signed in
')
return
links = 'HomeMy accountSign-in security'
diff --git a/tests/test_account_identity_disclosure.py b/tests/test_account_identity_disclosure.py
new file mode 100644
index 0000000..9861e7c
--- /dev/null
+++ b/tests/test_account_identity_disclosure.py
@@ -0,0 +1,106 @@
+"""An existing NetKingdom sign-in is shown before the account site continues it."""
+import unittest
+from urllib.parse import parse_qs, urlparse
+
+import test_portal_navigation
+from test_web import invoke
+
+
+class RecordingLookup:
+ def __init__(self, result):
+ self.result = result
+ self.headers = []
+
+ def username(self, header):
+ self.headers.append(header)
+ if isinstance(self.result, BaseException):
+ raise self.result
+ if callable(self.result):
+ return self.result(header)
+ return self.result
+
+
+class AccountIdentityDisclosureTests(unittest.TestCase):
+ setUp = test_portal_navigation.PortalNavigationTests.setUp
+
+ def test_confirmed_sign_in_is_named_before_the_account_site_continues(self):
+ def answer(header):
+ if "authelia_session=super-secret-session" in header:
+ return "platform-root"
+ return None
+
+ self.app.identity_lookup = RecordingLookup(answer)
+ cookie = "ue_session=absent; authelia_session=super-secret-session"
+ response, body = invoke(self.app, "/", cookie=cookie)
+ self.assertEqual("200 OK", response["status"])
+ self.assertEqual(1, len(self.app.identity_lookup.headers))
+ self.assertIn(b"NetKingdom sign-in is platform-root", body)
+ self.assertIn(b"This account site has no session yet.", body)
+ self.assertIn(b'href="/login">Continue as platform-root', body)
+ self.assertIn(b'href="/login?fresh=1">Use a different identity', body)
+ self.assertNotIn(b"Not signed in", body)
+ self.assertNotIn(b"You are not signed in.", body)
+ self.assertNotIn(b"Signed in as", body)
+ self.assertNotIn(b"super-secret-session", body)
+ self.assertNotIn(b"Active now", body)
+
+ _response, body = invoke(self.app, "/")
+ self.assertIn(b'href="/login">Sign in', body)
+ self.assertIn(b"Not signed in", body)
+ self.assertNotIn(b"platform-root", body)
+ self.assertEqual(2, len(self.app.identity_lookup.headers))
+
+ def test_account_session_is_not_replaced_by_the_netkingdom_cookie(self):
+ self.app.identity_lookup = RecordingLookup("platform-root")
+ _, body = invoke(
+ self.app, "/", cookie="ue_session=member; authelia_session=super-secret-session"
+ )
+ self.assertIn(b"Signed in as", body)
+ self.assertIn(b"sample.user", body)
+ self.assertNotIn(b"platform-root", body)
+ self.assertNotIn(b'href="/login"', body)
+ self.assertEqual([], self.app.identity_lookup.headers)
+
+ def test_lookup_failure_or_unsafe_name_stays_signed_out(self):
+ for result in [TimeoutError("slow"), "", "platform root"]:
+ with self.subTest(result=result):
+ self.app.identity_lookup = RecordingLookup(result)
+ _, body = invoke(self.app, "/", cookie="authelia_session=opaque")
+ self.assertIn(b'href="/login">Sign in', body)
+ self.assertIn(b"You are not signed in.", body)
+ self.assertNotIn(b"Signed in as", body)
+ self.assertNotIn(b"