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
This commit is contained in:
parent
b987a3de9e
commit
560cdeed46
9 changed files with 500 additions and 19 deletions
|
|
@ -9,11 +9,17 @@ headless capability alone does not mean a journey is usable or verified live.
|
||||||
|
|
||||||
## Common interaction rules
|
## Common interaction rules
|
||||||
|
|
||||||
- The header is titled NetKingdom Identity. It states “Signed in as” the
|
- The header is titled NetKingdom Identity. An account-site session says
|
||||||
verified identity, or “Not signed in.” A valid account-site session shows
|
“Signed in as” the verified identity and offers Log out. With no account-site
|
||||||
Log out; an absent or expired session shows Sign in. A one-time code raises
|
session, the header says “Not signed in” and offers Sign in. When the browser
|
||||||
the security level of the NetKingdom sign-in and is not another sign-in.
|
already sent a NetKingdom session cookie, the account site asks the sign-in
|
||||||
Never infer identity from URL parameters or an existing provider tab.
|
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
|
- The portal cannot observe every application or shared-provider session. Explain
|
||||||
this once in sign-out confirmation or expandable identity-switch help, not as
|
this once in sign-out confirmation or expandable identity-switch help, not as
|
||||||
competing login/logout actions everywhere. “Use another account” remains
|
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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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) |
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,14 @@ non_tooling_clients:
|
||||||
write: false
|
write: false
|
||||||
note: Consumes identity claims as PIP input. Not a key-cape admin client.
|
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
|
- id: env-injected-secrets
|
||||||
target: Railiance secret injection
|
target: Railiance secret injection
|
||||||
layer: not-catalogued
|
layer: not-catalogued
|
||||||
|
|
|
||||||
114
src/user_engine/identity_state.py
Normal file
114
src/user_engine/identity_state.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -53,7 +53,14 @@ class OIDCClient:
|
||||||
self.pending: dict[str, PendingLogin] = {}
|
self.pending: dict[str, PendingLogin] = {}
|
||||||
self.sessions: dict[str, BrowserSession] = {}
|
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"}:
|
if return_path not in {"/", "/platform", "/platform/factor-recovery", "/platform/authentication-policy"}:
|
||||||
raise ValueError("unsupported login return path")
|
raise ValueError("unsupported login return path")
|
||||||
state = secrets.token_urlsafe(32)
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
@ -72,6 +79,10 @@ class OIDCClient:
|
||||||
}
|
}
|
||||||
if recovery:
|
if recovery:
|
||||||
parameters.update(prompt="login", max_age="0", acr_values="aal2")
|
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:
|
if tenant_hint:
|
||||||
parameters["tenant_hint"] = tenant_hint
|
parameters["tenant_hint"] = tenant_hint
|
||||||
return f"{self.issuer}/authorize?{urlencode(parameters)}"
|
return f"{self.issuer}/authorize?{urlencode(parameters)}"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from user_engine.adapters import (
|
||||||
HTTPTenantManagementAdapter,
|
HTTPTenantManagementAdapter,
|
||||||
)
|
)
|
||||||
from user_engine.service import UserEngineService
|
from user_engine.service import UserEngineService
|
||||||
|
from user_engine.identity_state import AutheliaIdentityState
|
||||||
from user_engine.oidc import OIDCClient
|
from user_engine.oidc import OIDCClient
|
||||||
from user_engine.web import PortalApplication
|
from user_engine.web import PortalApplication
|
||||||
from user_engine.factor_recovery import FactorRecoveryClient
|
from user_engine.factor_recovery import FactorRecoveryClient
|
||||||
|
|
@ -118,6 +119,11 @@ def create_application() -> PortalApplication:
|
||||||
registration_rate_window_seconds=int(
|
registration_rate_window_seconds=int(
|
||||||
os.environ.get("USER_ENGINE_REGISTRATION_RATE_WINDOW_SECONDS", "60")
|
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
|
from user_engine.operations_status import check_services
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from user_engine.domain import (
|
||||||
PrincipalType,
|
PrincipalType,
|
||||||
)
|
)
|
||||||
from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
|
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.oidc import OIDCClient, cookie_value
|
||||||
from user_engine.ports import (
|
from user_engine.ports import (
|
||||||
IdentityProvisioningPort,
|
IdentityProvisioningPort,
|
||||||
|
|
@ -50,6 +51,7 @@ StartResponse = Callable[[str, list[tuple[str, str]]], Any]
|
||||||
# Rendering state is scoped to one request, including concurrent WSGI requests.
|
# Rendering state is scoped to one request, including concurrent WSGI requests.
|
||||||
_ACCOUNT_NAVIGATION: ContextVar[str] = ContextVar("account_navigation", default="")
|
_ACCOUNT_NAVIGATION: ContextVar[str] = ContextVar("account_navigation", default="")
|
||||||
_BROWSER_REQUEST: ContextVar[bool] = ContextVar("browser_request", default=False)
|
_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:
|
def _jsonable(value: Any) -> Any:
|
||||||
|
|
@ -89,6 +91,7 @@ class PortalApplication:
|
||||||
registration_password_setup_origins: tuple[str, ...] = (),
|
registration_password_setup_origins: tuple[str, ...] = (),
|
||||||
registration_rate_limit: int = 10,
|
registration_rate_limit: int = 10,
|
||||||
registration_rate_window_seconds: int = 60,
|
registration_rate_window_seconds: int = 60,
|
||||||
|
identity_lookup: Any | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if len(trusted_proxy_secret) < 24:
|
if len(trusted_proxy_secret) < 24:
|
||||||
raise ValueError("trusted proxy secret must contain at least 24 characters")
|
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_rate_window_seconds = registration_rate_window_seconds
|
||||||
self._registration_attempts: dict[str, deque[float]] = {}
|
self._registration_attempts: dict[str, deque[float]] = {}
|
||||||
self._registration_attempts_lock = Lock()
|
self._registration_attempts_lock = Lock()
|
||||||
|
self.identity_lookup = identity_lookup
|
||||||
|
|
||||||
def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]:
|
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)}"
|
correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}"
|
||||||
navigation_token = _ACCOUNT_NAVIGATION.set("")
|
navigation_token = _ACCOUNT_NAVIGATION.set("")
|
||||||
|
cache_token = _REQUEST_CACHE.set({})
|
||||||
browser_token = _BROWSER_REQUEST.set(
|
browser_token = _BROWSER_REQUEST.set(
|
||||||
"text/html" in str(environ.get("HTTP_ACCEPT", ""))
|
"text/html" in str(environ.get("HTTP_ACCEPT", ""))
|
||||||
and not str(environ.get("PATH_INFO", "/")).startswith("/api/")
|
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)
|
return self._error(start_response, "400 Bad Request", "invalid_json", "Malformed request body.", correlation_id)
|
||||||
finally:
|
finally:
|
||||||
_ACCOUNT_NAVIGATION.reset(navigation_token)
|
_ACCOUNT_NAVIGATION.reset(navigation_token)
|
||||||
|
_REQUEST_CACHE.reset(cache_token)
|
||||||
_BROWSER_REQUEST.reset(browser_token)
|
_BROWSER_REQUEST.reset(browser_token)
|
||||||
|
|
||||||
def _dispatch(self, environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str) -> Iterable[bytes]:
|
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]
|
tenant_hint = query.get("tenant_hint", [None])[0]
|
||||||
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
|
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
|
||||||
raise ValidationError("tenant_hint must be a tenant identifier")
|
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 = (
|
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
|
if self.oidc_client else self.login_url
|
||||||
)
|
)
|
||||||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||||
|
|
@ -225,10 +244,17 @@ class PortalApplication:
|
||||||
except AuthorizationDenied:
|
except AuthorizationDenied:
|
||||||
actor = None
|
actor = None
|
||||||
self._set_account_navigation(environ, actor)
|
self._set_account_navigation(environ, actor)
|
||||||
|
if actor is not None:
|
||||||
identity = (
|
identity = (
|
||||||
f'<p>Signed in as <strong>{escape(actor.preferred_username or actor.subject)}</strong>.</p>'
|
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>'
|
'<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>'
|
)
|
||||||
|
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(
|
return self._html(start_response, self._page_html(
|
||||||
"Sign-in help", '<h1>Sign-in could not be completed</h1>'
|
"Sign-in help", '<h1>Sign-in could not be completed</h1>'
|
||||||
|
|
@ -242,11 +268,23 @@ class PortalApplication:
|
||||||
if path == "/logged-out" and method == "GET":
|
if path == "/logged-out" and method == "GET":
|
||||||
if self._optional_actor(environ) is not None:
|
if self._optional_actor(environ) is not None:
|
||||||
return self._redirect(start_response, "/", correlation_id)
|
return self._redirect(start_response, "/", correlation_id)
|
||||||
return self._html(start_response, self._page_html(
|
pending = self._pending_identity_copy(self._netkingdom_name())
|
||||||
"Not signed in",
|
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>'
|
'<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>'
|
'<p>Your shared NetKingdom sign-in may still be active. '
|
||||||
+ self._identity_switch_help(),
|
'Opening the account site again may reuse that identity.</p>'
|
||||||
|
)
|
||||||
|
return self._html(start_response, self._page_html(
|
||||||
|
title,
|
||||||
|
signed_out + self._identity_switch_help(),
|
||||||
), correlation_id)
|
), correlation_id)
|
||||||
if path == "/logout" and method == "GET":
|
if path == "/logout" and method == "GET":
|
||||||
actor = self._optional_actor(environ)
|
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>""",
|
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
def _login_state(self, actor: Any | None) -> str:
|
||||||
def _login_state(actor: Any | None) -> str:
|
|
||||||
level = (
|
level = (
|
||||||
"<p>A one-time code raises the security level of a NetKingdom sign-in. "
|
"<p>A one-time code raises the security level of a NetKingdom sign-in. "
|
||||||
"It is not another sign-in.</p>"
|
"It is not another sign-in.</p>"
|
||||||
)
|
)
|
||||||
if actor is None:
|
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 (
|
return (
|
||||||
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
|
'<section aria-labelledby="login-state"><h2 id="login-state">Identity</h2>'
|
||||||
"<p>You are not signed in.</p>"
|
"<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)])
|
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||||
return [b""]
|
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:
|
def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None:
|
||||||
if actor is 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>')
|
_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
|
return
|
||||||
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
|
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
|
||||||
|
|
|
||||||
106
tests/test_account_identity_disclosure.py
Normal file
106
tests/test_account_identity_disclosure.py
Normal file
|
|
@ -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 <strong>platform-root</strong>", 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"), "<script>alert(1)</script>", "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"<script>", body)
|
||||||
|
self.assertNotIn(b"platform root", body)
|
||||||
|
|
||||||
|
def test_logged_out_and_recovery_name_the_same_sign_in(self):
|
||||||
|
self.app.identity_lookup = RecordingLookup("platform-root")
|
||||||
|
cookie = "authelia_session=super-secret-session"
|
||||||
|
_, logged_out = invoke(self.app, "/logged-out", cookie=cookie)
|
||||||
|
self.assertIn(b"NetKingdom sign-in is <strong>platform-root</strong>", logged_out)
|
||||||
|
self.assertIn(b"This account site has no session yet.", logged_out)
|
||||||
|
self.assertNotIn(b"may still be active", logged_out)
|
||||||
|
self.assertNotIn(b"super-secret-session", logged_out)
|
||||||
|
self.assertIn(b"https://kc.example/account/logout", logged_out)
|
||||||
|
_, recovery = invoke(self.app, "/access-recovery", cookie=cookie)
|
||||||
|
self.assertIn(b"NetKingdom sign-in is <strong>platform-root</strong>", recovery)
|
||||||
|
self.assertNotIn(b"Signed in as", recovery)
|
||||||
|
self.assertIn(b"/logout", recovery)
|
||||||
|
|
||||||
|
def test_different_identity_requests_a_fresh_sign_in(self):
|
||||||
|
response, _body = invoke(self.app, "/login", query="fresh=1")
|
||||||
|
location = response["headers"]["Location"]
|
||||||
|
query = parse_qs(urlparse(location).query)
|
||||||
|
self.assertEqual(["login"], query["prompt"])
|
||||||
|
self.assertEqual(["0"], query["max_age"])
|
||||||
|
self.assertNotIn("acr_values", query)
|
||||||
|
|
||||||
|
def test_query_parameters_do_not_invent_the_shown_identity(self):
|
||||||
|
self.app.identity_lookup = RecordingLookup("platform-root")
|
||||||
|
_, body = invoke(
|
||||||
|
self.app,
|
||||||
|
"/",
|
||||||
|
query="username=forged",
|
||||||
|
cookie="authelia_session=super-secret-session",
|
||||||
|
)
|
||||||
|
self.assertIn(b"platform-root", body)
|
||||||
|
self.assertNotIn(b"forged", body)
|
||||||
141
tests/test_identity_state.py
Normal file
141
tests/test_identity_state.py
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
"""The account site may name a NetKingdom sign-in only after Authelia confirms it."""
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
from user_engine.identity_state import (
|
||||||
|
AutheliaIdentityState,
|
||||||
|
authelia_session_token,
|
||||||
|
username_from_state,
|
||||||
|
validate_identity_state_url,
|
||||||
|
_read_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityStateUrlTests(unittest.TestCase):
|
||||||
|
def test_accepts_the_cluster_state_endpoint_and_public_https(self):
|
||||||
|
cluster = "http://authelia.sso.svc.cluster.local:9091/api/state"
|
||||||
|
public = "https://login.coulomb.social/api/state"
|
||||||
|
self.assertEqual(cluster, validate_identity_state_url(cluster))
|
||||||
|
self.assertEqual(public, validate_identity_state_url(public))
|
||||||
|
|
||||||
|
def test_rejects_anything_that_could_carry_the_session_cookie_elsewhere(self):
|
||||||
|
for url in [
|
||||||
|
"http://login.coulomb.social/api/state",
|
||||||
|
"http://authelia.sso.svc.cluster.local.example/api/state",
|
||||||
|
"http://169.254.169.254/api/state",
|
||||||
|
"https://user:pass@login.coulomb.social/api/state",
|
||||||
|
"https://login.coulomb.social/api/state?next=1",
|
||||||
|
"https://login.coulomb.social/api/state#fragment",
|
||||||
|
"https://login.coulomb.social/api/userinfo",
|
||||||
|
"https://login.coulomb.social/api/state/",
|
||||||
|
" https://login.coulomb.social/api/state",
|
||||||
|
"http://svc.cluster.local/api/state",
|
||||||
|
]:
|
||||||
|
with self.subTest(url=url):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
validate_identity_state_url(url)
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityStateParseTests(unittest.TestCase):
|
||||||
|
def test_wrapped_and_flat_confirmed_usernames_are_accepted(self):
|
||||||
|
wrapped = {
|
||||||
|
"status": "OK",
|
||||||
|
"data": {"username": "platform-root", "authentication_level": 1},
|
||||||
|
}
|
||||||
|
flat = {"username": "bernd.worsch-99", "authentication_level": 2}
|
||||||
|
self.assertEqual("platform-root", username_from_state(wrapped))
|
||||||
|
self.assertEqual("bernd.worsch-99", username_from_state(flat))
|
||||||
|
|
||||||
|
def test_unconfirmed_or_unsafe_answers_are_ignored(self):
|
||||||
|
for payload in [
|
||||||
|
{"status": "OK", "data": {"username": "", "authentication_level": 0}},
|
||||||
|
{"status": "OK", "data": {"username": "platform-root", "authentication_level": 0}},
|
||||||
|
{"status": "OK", "data": {"username": "platform-root", "authentication_level": True}},
|
||||||
|
{"status": "KO", "data": {"username": "platform-root", "authentication_level": 1}},
|
||||||
|
{"username": "<script>", "authentication_level": 1},
|
||||||
|
{"username": "platform root", "authentication_level": 1},
|
||||||
|
{"data": {"username": "platform-root"}},
|
||||||
|
[],
|
||||||
|
]:
|
||||||
|
with self.subTest(payload=payload):
|
||||||
|
self.assertIsNone(username_from_state(payload))
|
||||||
|
|
||||||
|
def test_cookie_token_rejects_injection_and_other_cookies(self):
|
||||||
|
header = "ue_session=keep; authelia_session=opaque.token-1; other=no"
|
||||||
|
self.assertEqual("opaque.token-1", authelia_session_token(header))
|
||||||
|
self.assertIsNone(authelia_session_token("authelia_session=bad\r\nCookie: x"))
|
||||||
|
self.assertIsNone(authelia_session_token("authelia_session=" + ("a" * 4097)))
|
||||||
|
self.assertIsNone(authelia_session_token(""))
|
||||||
|
|
||||||
|
def test_lookup_sends_only_the_session_token_and_fails_closed(self):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def reader(url, token, timeout):
|
||||||
|
seen.append((url, token, timeout))
|
||||||
|
if token == "broken":
|
||||||
|
raise TimeoutError("slow")
|
||||||
|
if token == "odd":
|
||||||
|
return b'{"status":"OK","data":{"username":"platform-root","authentication_level":1}}'
|
||||||
|
return b"not-json"
|
||||||
|
|
||||||
|
state = AutheliaIdentityState(
|
||||||
|
"http://authelia.sso.svc.cluster.local:9091/api/state",
|
||||||
|
reader=reader,
|
||||||
|
)
|
||||||
|
header = "ue_session=secret; authelia_session=odd; theme=dark"
|
||||||
|
self.assertEqual("platform-root", state.username(header))
|
||||||
|
self.assertEqual(
|
||||||
|
[("http://authelia.sso.svc.cluster.local:9091/api/state", "odd", 2.0)],
|
||||||
|
seen,
|
||||||
|
)
|
||||||
|
self.assertIsNone(state.username("authelia_session=broken"))
|
||||||
|
self.assertIsNone(state.username("authelia_session=plain"))
|
||||||
|
self.assertIsNone(state.username("authelia_session=bad\r\nX"))
|
||||||
|
self.assertNotIn("secret", json.dumps(seen))
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityStateTransportTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.seen = []
|
||||||
|
parent = self
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
parent.seen.append((self.path, self.headers.get("Cookie")))
|
||||||
|
if self.path == "/api/state":
|
||||||
|
body = json.dumps(
|
||||||
|
{"status": "OK", "data": {"username": "platform-root", "authentication_level": 1}}
|
||||||
|
).encode()
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header("Location", "http://127.0.0.1:9/stolen")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
self.port = self.server.server_address[1]
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
|
||||||
|
def test_transport_sends_one_cookie_and_does_not_follow_redirects(self):
|
||||||
|
url = f"http://127.0.0.1:{self.port}/api/state"
|
||||||
|
body = _read_state(url, "opaque-token", 1)
|
||||||
|
self.assertIn(b"platform-root", body)
|
||||||
|
self.assertEqual([("/api/state", "authelia_session=opaque-token")], self.seen)
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
_read_state(f"http://127.0.0.1:{self.port}/redirect", "opaque-token", 1)
|
||||||
|
self.assertEqual("/redirect", self.seen[-1][0])
|
||||||
|
self.assertNotIn("/stolen", [path for path, _cookie in self.seen])
|
||||||
|
|
@ -21,6 +21,12 @@ class OIDCClientTests(unittest.TestCase):
|
||||||
self.assertIn(query["state"][0], self.client.pending)
|
self.assertIn(query["state"][0], self.client.pending)
|
||||||
self.assertNotIn(self.client.pending[query["state"][0]].verifier, url.query)
|
self.assertNotIn(self.client.pending[query["state"][0]].verifier, url.query)
|
||||||
|
|
||||||
|
def test_fresh_begin_asks_for_a_new_sign_in_without_raising_assurance(self):
|
||||||
|
query = parse_qs(urlparse(self.client.begin(fresh=True)).query)
|
||||||
|
self.assertEqual(["login"], query["prompt"])
|
||||||
|
self.assertEqual(["0"], query["max_age"])
|
||||||
|
self.assertNotIn("acr_values", query)
|
||||||
|
|
||||||
def test_begin_can_forward_a_tenant_hint_without_changing_session_authority(self):
|
def test_begin_can_forward_a_tenant_hint_without_changing_session_authority(self):
|
||||||
url = urlparse(self.client.begin(tenant_hint="tenant:friendly:binky"))
|
url = urlparse(self.client.begin(tenant_hint="tenant:friendly:binky"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue