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)}.

' - '

View my account and access

' - if actor else '

You are not signed in. Open the account site with your NetKingdom identity.

' - ) + if actor is not None: + identity = ( + f'

Signed in as {escape(actor.preferred_username or actor.subject)}.

' + '

View my account and access

' + ) + else: + pending = self._pending_identity_copy(self._netkingdom_name()) + identity = ( + pending + if pending + else '

You are not signed in. Open the account site with your NetKingdom identity.

' + ) return self._html(start_response, self._page_html( "Sign-in help", '

Sign-in could not be completed

' '

Your account may not have access to the application, or sign-in may have been interrupted.

' @@ -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 = ( + "

This account site has no session yet.

" + + pending + ) + else: + title = "Not signed in" + signed_out = ( + '

You are not signed in.

' + '

Your shared NetKingdom sign-in may still be active. ' + 'Opening the account site again may reuse that identity.

' + ) return self._html(start_response, self._page_html( - "Not signed in", - '

You are not signed in.

' - '

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.

Onboarding progress

""", ) - @staticmethod - def _login_state(actor: Any | None) -> str: + def _login_state(self, actor: Any | None) -> str: level = ( "

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"

NetKingdom sign-in is {safe}.

" + "

This account site has no session yet.

" + f'

Continue as {safe} ' + 'Use a different identity

' + ) + 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"