Clarify account session controls and add authenticator recovery guidance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 50s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-13 10:09:01 +02:00
parent 61dc76739f
commit a9ff77d21d
8 changed files with 395 additions and 26 deletions

112
docs/account-journeys.md Normal file
View file

@ -0,0 +1,112 @@
# Account and access journeys
Owner: user-engine, with KeyCape/NetKingdom for sign-in and factors,
tenant-engine for tenant lifecycle, and applications for workload admission.
Acceptance work: USER-WP-0027; OTP dependency: KEY-WP-0035 and NK-WP-0033.
Reviewed against the portal on 2026-09-13. This is the browser acceptance contract;
headless capability alone does not mean a journey is usable or verified live.
## Common interaction rules
- The header states the verified portal identity, or “Not signed in to this
portal.” A valid portal session shows Log out; an absent/expired session shows
Sign in. Never infer identity from URL parameters or an existing provider tab.
- 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
reachable when a wrong shared identity prevents portal login.
- Log out opens a confirmation with the scope made explicit. GET never signs out.
Invalid confirmation leaves the current session intact. Do not claim global
sign-out when only the portal session ended.
- Name the person, selected tenant, and application separately. Platform
administration is not tenant membership and does not imply application access.
- Every action has a visible result and a useful next step. Failure is not success;
unknown provider state is not “disabled.” Keep recovery reachable before login.
- Use one primary action per step, familiar wording, labelled fields and errors,
keyboard-visible focus, and layouts usable on a phone. Preserve safe form input
after validation failure; never redisplay secrets or trust unverified returns.
- A status change requires authoritative confirmation. A sent message is not
delivered mail; an invitation is not a login; opening a setup link is not OTP
activation; recorded membership is not guaranteed workload admission.
## User perspective
| ID / intent | Success | Failure and recovery | Current support / acceptance |
|---|---|---|---|
| U01 — Know whether I am signed in | Header names my verified account; exactly the appropriate Sign in or Log out control | Expired/unknown cookie shows signed-out state; sign in again | Implemented; automated anonymous/expired/member/operator tests |
| 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; email delivery unresolved; display-name/login-name clarity needs work |
| U05 — Use password-only access before optional OTP enrollment | Ordinary application permits login when provider confirms no activated factor | Provider unavailable gives recovery, never silently bypasses enrolled OTP | KEY-WP-0035 source tested; live credential/policy gate unresolved |
| U06 — Turn on authenticator codes voluntarily | My account → Sign-in security → provider; confirm identity, scan QR, verify current code, see activation confirmed, test fresh login | Bad code retries; cancellation does not report enabled; interruption can resume safely; support reachable without portal login | Help and configurable provider handoff implemented; provider activation/cancel semantics and live enrollment unverified |
| U07 — Sign in with an enrolled authenticator | Current code completes login; existing AAL1 session cannot skip OTP | Invalid code explains retry; lost device has a recovery route | Issuer policy tested; real-user enrolled/recovery acceptance pending |
| U08 — Replace or remove my authenticator | Provider reauthenticates; replacement verified before old factor removed; status and recovery instructions clear | Lost old factor triggers verified recovery, not a bypass link; policy-required MFA cannot be disabled | Required provider journey; not verified/available from portal yet |
| U09 — See my tenants and usable applications | Account page lists allowed tenants and launchable applications with the current context | Empty membership explains request-access route; unavailable/stale access is labelled and refreshable | Personal membership display exists; authoritative application catalogue/request-access journey pending USER-WP-0026-T03 |
| U10 — Change tenant or account | Explicit tenant switch confirms new authority; account switch ends relevant sessions and lets me choose identity | Denied tenant leaves a clear recovery route; stale shared identity can be cleared | Tenant reauthentication and shared sign-out implemented; real multi-identity acceptance pending |
| U11 — Sign out | Confirmation states scope; portal session ends; correct signed-out controls appear | CSRF rejection retains session; shared-provider failure explains remaining scope and retry | Portal automated tests; prior shared logout browser checks; current real-user acceptance pending |
| U12 — Recover from denied access or service outage | Plain explanation, reference for support, and account/help navigation | No automatic login loop; no private claims/codes echoed; safe retry only | HTML browser denial/recovery implemented; JSON API semantics retained |
| U13 — Update my profile and finish onboarding | Saved values and required steps are confirmed; external steps reflect provider evidence | Validation keeps safe input; provider-owned steps cannot be manually faked complete | Existing routes; form-preservation and external completion UX acceptance pending |
## Tenant administrator perspective
| ID / intent | Success | Failure and recovery | Current support / acceptance |
|---|---|---|---|
| T01 — Enter the right tenant administration | Header shows identity; managed tenant is explicit; only permitted admin navigation | Non-admin/cross-tenant request denied with account recovery | Existing authorization/navigation tests; broader browser matrix pending |
| T02 — Invite someone with the right role | Review name, email, tenant and role; show invitation state and next action | Duplicate, wrong address, expired invite: inspect, correct/reissue or expire without making a second account | Invitation routes and checks exist; delivery/preview usability pending |
| T03 — Prepare an account that can actually log in | Distinguish profile, directory login name, invitation, password setup, and tenant access; admin can give the correct login name | Partial provisioning shows what exists and retry reconciles it; never show display name as login implicitly | Create-login/setup-link routes exist; plain login-name presentation and lifecycle state view pending |
| T04 — Help someone who cannot sign in | Identify affected tenant/account; distinguish password, OTP, membership, and outage; give safe recovery step | No access to passwords, OTP seed or current codes; provider failure has support reference/escalation | Password setup and help page exist; verified lost-factor recovery/provider status pending |
| T05 — Grant/change/revoke application access | Review exact tenant/application/role; apply authorized change; confirm effective result | Policy denial or stale version explains reason and refresh; no silent broad grant | Service capabilities vary; consolidated browser application-access management pending |
| T06 — Suspend/reactivate/remove a tenant account | Confirm target and scope; show resulting state and whether access propagation is pending | Stale or failed operation leaves truthful state; retry after readback; shared identity in other tenants preserved | Existing lifecycle routes; confirmation/propagation UX and cross-tenant browser drills pending |
| T07 — Track incomplete onboarding | See invited, identity missing, password pending, OTP problem, and access denied as distinct actionable states | Stale/unknown provider state is labelled; administrator gets the correct owner/action | Headless diagnostics exist; consolidated browser status and retry workflow pending |
| T08 — Manage admin succession and audit | Grant the next admin only with authority; preserve an accountable admin; review who changed what | Prevent loss of last administrator or provide platform recovery; no self-escalation | Required journey; last-admin guard and browser audit acceptance need review |
## Platform administrator perspective
| ID / intent | Success | Failure and recovery | Current support / acceptance |
|---|---|---|---|
| P01 — Administer platform without joining every tenant | Platform console reachable with platform role; personal memberships remain truthful | Opening a workload as platform admin can be denied; account console remains reachable | Existing platform navigation/authority tests; full workload denial journey acceptance pending |
| P02 — Create tenant and first administrator | Confirm tenant identifier/type/name and first admin; show tenant created versus admin onboarding pending | Conflict/partial failure readback avoids duplicate tenant; resume only missing stages | Existing platform creation/result routes; live end-to-end drill pending |
| P03 — Find and inspect a tenant | Search exact identifier or disambiguate matching names; clearly state selected scope | Unknown/ambiguous name presents choices without guessing authority | Existing tests for unknown/ambiguous names and tenant selection |
| P04 — Recover a tenant administrator | Verify request and affected scope; audited, bounded account/factor recovery returns control | Insufficient proof denies; provider outage yields pending state and named owner | Identity-recovery route exists; lost-factor recovery and audited browser acceptance pending |
| P05 — Operate sign-in, factor and notification services | See degraded component and impacted journeys; renew scoped credentials; verify recovery | Expired factor-read credential blocks assurance decision; missing mail uses explicit assisted setup; no hidden MFA bypass | Current incident: rejected factor-read credential; custody/renewal and mail delivery unresolved |
| P06 — Set authentication policy | Distinguish optional-after-enrollment, mandatory, and application step-up; preview impact and rollback | Unsupported/ambiguous policy rejected; avoid locking out unenrolled users or weakening privileged actions | KeyCape optional policy source tested; scoped rollout and privileged-portal policy acceptance pending |
| P07 — Suspend/retire/reactivate tenant | Confirm blast radius and resulting tenant lifecycle; applications reflect effective policy | Stale version or partial propagation provides readback/retry; recovery retains audit | Existing tenant lifecycle APIs; browser confirmation/propagation drill pending |
| P08 — Review audit and service recovery | Follow correlation reference without exposing secrets; see actor, tenant, action and result; prove rollback | Missing telemetry or unknown state visible; no success until authoritative readback | Existing service events/references; unified browser audit/support workflow pending |
## OTP activation state contract
The provider owns credential material and proof of possession. The portal owns
explanation, navigation, and handoff; it never collects OTP seeds or codes.
| State | What the user sees | Allowed next step | Required evidence |
|---|---|---|---|
| Unknown / unavailable | “We cannot confirm your authenticator status”; setup unavailable when no verified handoff is configured | Help; provider management only when operator-configured and verified | Do not infer off from lookup failure or AAL1 session |
| Not enrolled | Optional setup, plus continue without OTP where policy permits | Start enrollment | Authoritative factor absence and applicable client policy |
| Enrollment pending | QR/code confirmation at provider; no success badge yet | Confirm, retry, cancel or resume | Pending token cannot accidentally lock normal login |
| Enabled | Confirmation and recovery instructions | Test fresh sign-in; later manage device | Provider verifies possession and reports activated factor |
| Replacement pending | Old factor remains usable until replacement confirmed | Confirm replacement or cancel | No gap allowing MFA bypass |
| Recovery required | Clear lost-device recovery instructions | Verified recovery with audit | Provider proof/authorized recovery, never tenant membership alone |
Deployment gate: verify the live provider's activation, cancellation, realm mapping,
scoped factor-read credential and renewal contract before enabling optional policy
or setting USER_ENGINE_MFA_MANAGEMENT_URL. Both successful and empty token queries
must be tested against controlled accounts; self-service credentials are not an
administrative lookup credential. The portal help page is reachable before login
so OTP-required login does not hide recovery.
## Acceptance and remaining work
USER-WP-0027 tracks the matrix and role-based usability gaps. KEY-WP-0035 tracks
OTP policy/provider rollout. USER-WP-0026 retains authoritative workload catalogue
work. Do not close these based solely on this document or a unit-test pass.
Run every journey with an ordinary member, tenant admin, and platform operator as
applicable, plus an anonymous/expired session. For each capture: initial state,
visible controls, action, authoritative result, failure, recovery, and cross-tenant
isolation. Use synthetic identities in stored evidence. Include keyboard-only and
narrow-screen checks; never store passwords, QR images or OTP values in evidence.
Automated portal coverage: test_account_clarity.py, test_account_recovery.py,
test_portal_navigation.py and existing test_web.py authorization/lifecycle tests.
Automated issuer coverage: KEY-WP-0035 optional MFA tests. Actual provider OTP,
notification delivery and multi-user workload acceptance remain separate evidence.

View file

@ -70,3 +70,18 @@ Enabling it also requires the verification adapter and allow-lists:
The service fails closed when those settings are absent. Do not add a The service fails closed when those settings are absent. Do not add a
placeholder or reused credential to turn the flag on. placeholder or reused credential to turn the flag on.
## Authenticator management handoff
`USER_ENGINE_MFA_MANAGEMENT_URL` is optional and empty by default. When set, the
public `/security` help page offers a fixed HTTPS provider-management link. No
credentials, query parameters or fragments are allowed in this URL. The portal
never collects an OTP, reads an OTP seed, or marks a factor enabled from a return
URL. It cannot currently display authoritative enrollment status.
Enable only after verifying provider self-service identity checks, confirmation
before activation, cancellation/recovery, and enrolled-factor enforcement under
KEY-WP-0035. A reachable provider homepage alone is insufficient. Leave unset
while this dependency is unresolved; users get an explicit unavailable state and
help without needing to complete OTP login first. See [account journeys](account-journeys.md).

View file

@ -66,6 +66,7 @@ def create_application() -> PortalApplication:
service, service,
trusted_proxy_secret=_required("USER_ENGINE_PROXY_SECRET"), trusted_proxy_secret=_required("USER_ENGINE_PROXY_SECRET"),
login_url=_required("USER_ENGINE_LOGIN_URL"), login_url=_required("USER_ENGINE_LOGIN_URL"),
mfa_management_url=os.environ.get("USER_ENGINE_MFA_MANAGEMENT_URL", ""),
public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower() public_registration=os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower()
== "true", == "true",
oidc_client=OIDCClient( oidc_client=OIDCClient(

View file

@ -48,6 +48,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)
def _jsonable(value: Any) -> Any: def _jsonable(value: Any) -> Any:
@ -74,6 +75,7 @@ class PortalApplication:
trusted_proxy_secret: str, trusted_proxy_secret: str,
login_url: str, login_url: str,
public_registration: bool = True, public_registration: bool = True,
mfa_management_url: str = "",
oidc_client: OIDCClient | None = None, oidc_client: OIDCClient | None = None,
provisioning: IdentityProvisioningPort | None = None, provisioning: IdentityProvisioningPort | None = None,
tenant_management: TenantManagementPort | None = None, tenant_management: TenantManagementPort | None = None,
@ -92,6 +94,14 @@ class PortalApplication:
self.trusted_proxy_secret = trusted_proxy_secret self.trusted_proxy_secret = trusted_proxy_secret
self.login_url = login_url self.login_url = login_url
self.public_registration = public_registration self.public_registration = public_registration
if mfa_management_url:
destination = urlsplit(mfa_management_url)
if (destination.scheme != "https" or not destination.hostname
or destination.username or destination.password
or destination.query or destination.fragment
or any(c.isspace() for c in mfa_management_url)):
raise ValueError("MFA management URL must be a fixed HTTPS destination without credentials or query")
self.mfa_management_url = mfa_management_url
self.oidc_client = oidc_client self.oidc_client = oidc_client
self.provisioning = provisioning self.provisioning = provisioning
self.tenant_management = tenant_management self.tenant_management = tenant_management
@ -113,7 +123,15 @@ class PortalApplication:
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("")
browser_token = _BROWSER_REQUEST.set(
"text/html" in str(environ.get("HTTP_ACCEPT", ""))
and not str(environ.get("PATH_INFO", "/")).startswith("/api/")
and str(environ.get("PATH_INFO", "/")) not in {"/healthz", "/readyz", "/metrics"}
)
try: try:
if (not str(environ.get("PATH_INFO", "/")).startswith("/api/")
and str(environ.get("PATH_INFO", "/")) not in {"/healthz", "/readyz", "/metrics"}):
self._set_account_navigation(environ, self._optional_actor(environ))
return self._dispatch(environ, start_response, str(correlation_id)) return self._dispatch(environ, start_response, str(correlation_id))
except ConflictError as exc: except ConflictError as exc:
return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id) return self._error(start_response, "409 Conflict", "conflict", str(exc), correlation_id)
@ -135,6 +153,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)
_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]:
method = str(environ.get("REQUEST_METHOD", "GET")).upper() method = str(environ.get("REQUEST_METHOD", "GET")).upper()
@ -202,24 +221,25 @@ class PortalApplication:
identity = ( identity = (
f'<p>This portal is signed in as <strong>{escape(actor.preferred_username or actor.subject)}</strong>.</p>' f'<p>This portal is signed in as <strong>{escape(actor.preferred_username or actor.subject)}</strong>.</p>'
'<p><a class="button" href="/onboarding">View my account and access</a></p>' '<p><a class="button" href="/onboarding">View my account and access</a></p>'
if actor else '<p>Your identity has not been verified in this portal.</p>' if actor else '<p>You are not signed in to this portal. Sign in to verify your identity and access.</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>'
'<p>The application may not allow this account, or the sign-in service may have failed.</p>' '<p>Your account may not have access to the application, or sign-in may have been interrupted.</p>'
+ identity + identity
+ '<p><a href="/login">Verify my current identity</a></p>' + '<p><a href="/security">Help with passwords and verification codes</a></p>'
'<p><a href="/logout">Log out or use another account</a></p>', + (self._identity_switch_help() if actor is None else ""),
), correlation_id) ), correlation_id)
if path == "/security" and method == "GET":
return self._html(start_response, self._security_page(), correlation_id)
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( return self._html(start_response, self._page_html(
"Logged out", "Not signed in",
'<h1>You have logged out.</h1>' '<h1>You are not signed in to this portal.</h1>'
'<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>' '<p>Your shared NetKingdom sign-in may still be active. Signing in may reuse that account.</p>'
+ self._shared_logout_link() + self._identity_switch_help(),
+ '<p><a class="button" href="/login">Sign in</a></p>',
), 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)
@ -252,6 +272,8 @@ class PortalApplication:
return self._html(start_response, self._home(actor), correlation_id) return self._html(start_response, self._home(actor), correlation_id)
if path == "/register" and method == "GET": if path == "/register" and method == "GET":
if self._optional_actor(environ) is not None:
return self._redirect(start_response, "/onboarding", correlation_id)
if not self.public_registration or self.registration_verification is None: if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable") raise NotFoundError("public registration is unavailable")
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
@ -1544,21 +1566,56 @@ class PortalApplication:
raise ValidationError("Idempotency-Key must contain at least 16 characters") raise ValidationError("Idempotency-Key must contain at least 16 characters")
return value return value
def _shared_logout_link(self) -> str: def _identity_switch_help(self) -> str:
if not self.oidc_client: if not self.oidc_client:
return "" return ""
return ( return (
f'<p><a class="button" href="{escape(self.oidc_client.issuer)}/account/logout">' '<details><summary>Wrong account appears when signing in?</summary>'
'Sign out of NetKingdom to use another account</a></p>' '<p>You can clear the shared sign-in before choosing another account. '
'Other applications may keep their own sessions.</p>'
f'<p><a href="{escape(self.oidc_client.issuer)}/account/logout">'
'Use another account</a></p></details>'
) )
def _security_page(self) -> str:
handoff = (
'<p><a class="button" rel="noreferrer" href="'
+ escape(self.mfa_management_url)
+ '">Manage authenticator app</a></p>'
'<p>The sign-in service will ask you to verify your identity. Check the account name there before making changes.</p>'
if self.mfa_management_url else
'<p role="status">Authenticator setup is temporarily unavailable. '
'If a code is requested before you have set up an authenticator, contact your tenant administrator.</p>'
)
return self._page_html("Sign-in security", """
<h1>Sign-in security</h1>
<p>Use this page for help with your password and authenticator app.</p>
<section><h2>Two-step verification</h2>
<p>An authenticator app generates a short-lived code to enter after your password.
This portal cannot currently confirm whether an authenticator is enabled for your account.</p>
""" + handoff + """
<details><summary>How to set up an authenticator when setup is available</summary>
<ol><li>Open authenticator management and check that it shows your account.</li>
<li>Choose to add an authenticator and scan its QR code with your authenticator app.</li>
<li>Enter a current code to confirm setup. Wait for the sign-in service to confirm activation.</li>
<li>Follow the recovery instructions shown there, then test a new sign-in before closing your current session.</li></ol>
<p>Opening the setup page does not activate two-step verification. If you cancel, check the status in authenticator management before leaving.</p></details>
<details><summary>A code is rejected, or I have lost my authenticator</summary>
<p>Use the newest code for the correct account and check that your device sets its time automatically.
If you cannot use your authenticator, follow the sign-in service's recovery instructions or ask your tenant administrator for account recovery.
Never send your password, QR code, or verification codes to an administrator.</p></details></section>
<section><h2>Password help</h2><p>Use password recovery on the sign-in page.
If the recovery message does not arrive, ask your tenant administrator for a new password setup link.
Use the login name they provide; it may differ from your display name.</p></section>
<p><a href="/access-recovery">Back to sign-in help</a></p>""")
def _home(self, actor: Any | None) -> str: def _home(self, actor: Any | None) -> str:
identity = ( identity = (
f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>" f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>' '<p><a class="button" href="/onboarding">View my account</a></p>'
if actor is not None if actor is not None
else ( else (
f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>' '<p>You are not signed in to this portal.</p>'
+ ( + (
'<p>New here? <a href="/register">Create an account</a>.</p>' '<p>New here? <a href="/register">Create an account</a>.</p>'
if self.public_registration and self.registration_verification is not None if self.public_registration and self.registration_verification is not None
@ -1842,7 +1899,7 @@ class PortalApplication:
return self._page_html( return self._page_html(
"Onboarding", "Onboarding",
f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1> f"""<h1>Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}</h1>
<section aria-labelledby="verification"><h2 id="verification">Current identity</h2><p>Signed in as <strong>{escape(session.actor.preferred_username or session.actor.subject)}</strong>.</p><p>Sign-in tenant: {escape(session.actor.tenant)}.</p><p>Roles: {escape(", ".join(session.actor.roles) or "None")}.</p><p>{escape(verification)}</p><p>Passwords and MFA are managed by your identity provider.</p></section> <section aria-labelledby="verification"><h2 id="verification">Current identity</h2><p>Signed in as <strong>{escape(session.actor.preferred_username or session.actor.subject)}</strong>.</p><p>Sign-in tenant: {escape(session.actor.tenant)}.</p><p>Roles: {escape(", ".join(session.actor.roles) or "None")}.</p><p>{escape(verification)}</p><p><a href="/security">Password and two-step verification help</a></p></section>
<section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2> <section aria-labelledby="profile"><h2 id="profile">Profile and consent</h2>
<form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}"> <form method="post" action="/onboarding/profile"><input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label> <label>Display name <input name="display_name" required maxlength="200" autocomplete="name" value="{escape(session.user.display_name or '')}"></label>
@ -1911,9 +1968,9 @@ class PortalApplication:
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:
_ACCOUNT_NAVIGATION.set("") _ACCOUNT_NAVIGATION.set('<p>Not signed in to this portal</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>' links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
if "platform-operator" in actor.roles: if "platform-operator" in actor.roles:
links += '<a href="/platform">Platform administration</a>' links += '<a href="/platform">Platform administration</a>'
elif "tenant-admin" in actor.roles: elif "tenant-admin" in actor.roles:
@ -1921,12 +1978,9 @@ class PortalApplication:
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
csrf = self.oidc_client.csrf_token(session_id or "") if self.oidc_client else None csrf = self.oidc_client.csrf_token(session_id or "") if self.oidc_client else None
if csrf: if csrf:
links += ( links += '<a href="/logout">Log out</a>'
'<form method="post" action="/logout">' identity = escape(actor.preferred_username or actor.subject)
f'<input type="hidden" name="csrf_token" value="{escape(csrf)}">' _ACCOUNT_NAVIGATION.set(f'<p>Signed in to this portal as <strong>{identity}</strong></p><nav aria-label="Account navigation">' + links + '</nav>')
'<button type="submit">Log out</button></form>'
)
_ACCOUNT_NAVIGATION.set('<nav aria-label="Account navigation">' + links + '</nav>')
@staticmethod @staticmethod
def _page_html(title: str, body: str) -> str: def _page_html(title: str, body: str) -> str:
@ -1993,6 +2047,20 @@ a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{o
return [data] return [data]
def _error(self, start_response: StartResponse, status: str, code: str, message: str, correlation_id: str) -> list[bytes]: def _error(self, start_response: StartResponse, status: str, code: str, message: str, correlation_id: str) -> list[bytes]:
if _BROWSER_REQUEST.get():
title = "This action could not be completed"
guidance = "Check your account and access, then try again."
if code == "access_denied":
title = "Access is not available"
guidance = "You may need to sign in again, or your account may not have permission for this action."
elif code == "provisioning_unavailable":
guidance = "Account services are temporarily unavailable. Check the account status before retrying."
page = self._page_html(title, f'<h1>{title}</h1><p role="alert">{guidance}</p>'
'<p><a href="/access-recovery">Account and sign-in help</a></p>'
f'<p>If you need help, give your administrator this reference: <code>{escape(str(correlation_id))}</code>.</p>')
data = page.encode()
start_response(status, [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)])
return [data]
return self._json(start_response, status, {"error": {"code": code, "message": message, "correlation_id": correlation_id}}, correlation_id) return self._json(start_response, status, {"error": {"code": code, "message": message, "correlation_id": correlation_id}}, correlation_id)
@staticmethod @staticmethod

View file

@ -0,0 +1,77 @@
"""Role-independent browser session state and recovery acceptance checks."""
import unittest
import test_portal_navigation
from test_web import invoke
from user_engine.web import PortalApplication
class AccountClarityTests(unittest.TestCase):
setUp = test_portal_navigation.PortalNavigationTests.setUp
def test_anonymous_and_expired_sessions_have_login_without_logout(self):
self.oidc.sessions['operator'].expires_at = 0
for cookie in [None, 'ue_session=unknown', 'ue_session=operator']:
for path in ['/', '/access-recovery', '/logged-out', '/security']:
with self.subTest(path=path, cookie=cookie):
response, body = invoke(self.app, path, cookie=cookie)
self.assertEqual('200 OK', response['status'])
self.assertIn(b'href="/login">Sign in', body)
self.assertNotIn(b'href="/logout"', body)
self.assertNotIn(b'action="/logout"', body)
self.assertNotIn(b'Signed in to this portal as', body)
self.assertNotIn(b'You have logged out', body)
self.assertEqual('no-store', response['headers']['Cache-Control'])
def test_authenticated_roles_have_logout_without_login(self):
for who in ['operator', 'member']:
for path in ['/', '/access-recovery', '/security', '/onboarding']:
with self.subTest(path=path, who=who):
response, body = invoke(self.app, path, cookie='ue_session='+who)
self.assertEqual('200 OK', response['status'])
self.assertIn(b'Signed in to this portal as', body)
self.assertIn(b'href="/logout"', body)
self.assertNotIn(b'href="/login"', body)
self.assertNotIn(b'Verify my current identity', body)
response, _ = invoke(self.app, '/register', cookie='ue_session='+who)
self.assertEqual('/onboarding', response['headers']['Location'])
def test_wrong_shared_identity_recovery_does_not_claim_a_known_session(self):
_, body = invoke(self.app, '/access-recovery')
self.assertIn(b'<details><summary>Wrong account', body)
self.assertIn(b'https://kc.example/account/logout', body)
self.assertIn(b'Use another account', body)
self.assertNotIn(b'Sign out of NetKingdom', body)
def test_browser_denial_is_recoverable_while_api_remains_json(self):
for cookie in [None, 'ue_session=member']:
response, body = invoke(self.app, '/platform', cookie=cookie, headers={'HTTP_ACCEPT':'text/html'})
self.assertEqual('403 Forbidden', response['status'])
self.assertIn('text/html', response['headers']['Content-Type'])
self.assertIn(b'href="/access-recovery"', body)
self.assertIn(b'corr_test', body)
self.assertNotIn(b'href="/platform"', body)
response, body = invoke(self.app, '/api/v1/me', headers={'HTTP_ACCEPT':'text/html'})
self.assertEqual('403 Forbidden', response['status'])
self.assertIn('application/json', response['headers']['Content-Type'])
self.assertIn(b'"access_denied"', body)
def test_otp_help_is_available_without_portal_login_and_never_claims_activation(self):
response, body = invoke(self.app, '/security', query='enabled=true&username=forged&next=https://evil.example')
self.assertEqual('200 OK', response['status'])
self.assertIn(b'Authenticator setup is temporarily unavailable', body)
self.assertIn(b'cannot currently confirm', body)
self.assertNotIn(b'Manage authenticator app</a>', body)
for value in [b'forged', b'evil.example', b'<img', b'name="otp"']:
self.assertNotIn(value, body)
self.app.mfa_management_url='https://authenticator.example/account'
_, body = invoke(self.app, '/security')
self.assertIn(b'href="https://authenticator.example/account">Manage authenticator app', body)
self.assertIn(b'Wait for the sign-in service to confirm activation', body)
self.assertIn(b'Opening the setup page does not activate', body)
self.assertNotIn(b'Authenticator setup is temporarily unavailable', body)
def test_provider_handoff_rejects_unsafe_configuration(self):
for target in ['http://provider.example', '//provider.example', 'https://u:p@provider.example',
'https://provider.example/?next=evil', 'https://provider.example/#secret', 'https://bad host/']:
with self.subTest(target=target), self.assertRaises(ValueError):
PortalApplication(self.app.service, trusted_proxy_secret=self.app.trusted_proxy_secret,
login_url=self.app.login_url, mfa_management_url=target)

View file

@ -39,8 +39,7 @@ class PortalNavigationTests(unittest.TestCase):
response, body = self.get(path) response, body = self.get(path)
self.assertEqual('200 OK', response['status'], path) self.assertEqual('200 OK', response['status'], path)
self.assertIn(b'href="/platform"', body) self.assertIn(b'href="/platform"', body)
self.assertIn(b'action="/logout"', body) self.assertIn(b'href="/logout"', body)
self.assertIn(b'value="operator-csrf"', body)
_, body = self.get('/onboarding') _, body = self.get('/onboarding')
self.assertIn(b'no personal tenant memberships', body) self.assertIn(b'no personal tenant memberships', body)
self.assertIn(b'platform operator role', body) self.assertIn(b'platform operator role', body)

View file

@ -101,7 +101,7 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("ok", json.loads(payload)["status"]) self.assertEqual("ok", json.loads(payload)["status"])
home, html = invoke(self.app, "/") home, html = invoke(self.app, "/")
self.assertEqual("200 OK", home["status"]) self.assertEqual("200 OK", home["status"])
self.assertIn(b"Sign in with KeyCape", html) self.assertIn(b'href="/login">Sign in', html)
self.assertIn(b'name="viewport"', html) self.assertIn(b'name="viewport"', html)
self.assertIn(b"focus-visible", html) self.assertIn(b"focus-visible", html)
self.assertIn(b"<main>", html) self.assertIn(b"<main>", html)

View file

@ -0,0 +1,97 @@
---
id: USER-WP-0027
type: workplan
title: "Clear account state and complete user, tenant-admin and platform-admin journeys"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: communication
created: "2026-09-13"
updated: "2026-09-13"
---
## Establish role-based success, failure and recovery acceptance
```task
id: USER-WP-0027-T01
status: done
priority: high
```
Defined 29 journeys in docs/account-journeys.md: 13 user, 8 tenant administrator,
8 platform administrator. Each has success, failure/recovery and current support
status. The document is the browser acceptance contract, not proof all journeys
are implemented. Includes OTP state contract and cross-role/isolation checks.
## Make account controls and browser recovery reflect verified state
```task
id: USER-WP-0027-T02
status: done
priority: high
```
Signed-out/expired sessions show Sign in; verified sessions show identity and
Log out. Logout uses existing scope confirmation; wrong shared-identity recovery
stays accessible without implying a portal session. Signed-in registration routes
to account. Browser errors retain status and gain safe recovery navigation; APIs
remain JSON. Public /security explains OTP setup/recovery without claiming factor
state or activation. Fixed HTTPS provider handoff is configurable but disabled by
default pending provider verification. `make test`: 188 tests, 3 optional skips;
layer conformance passed. `git diff --check` passed.
## Publish and verify the portal UI cleanup
```task
id: USER-WP-0027-T03
status: progress
priority: high
```
Publish reviewed source, pin the CI image in rapp-user-engine, deploy with CAS,
verify readiness, signed-out browser controls, and recovery/security navigation.
Keep live OTP handoff disabled until T04 dependencies are met.
## Complete provider OTP activation and recovery path
```task
id: USER-WP-0027-T04
status: wait
priority: high
```
Depends on KEY-WP-0035-T02/T03 and NK-WP-0033 custody handoff. Current owner route
net-kingdom-privacyidea-admin-token remains non-resolvable. Restore scoped factor
lookup and verify possession-confirmed enrollment, cancellation, replacement,
recovery and fresh login; resolve privileged portal policy. Only then configure
USER_ENGINE_MFA_MANAGEMENT_URL and accept U05U08/P04P06. Do not fake a status from
assurance claims, redirect return parameters or manual step completion.
## Close tenant and platform administrator usability gaps
```task
id: USER-WP-0027-T05
status: todo
priority: high
```
Implement remaining T02T08/P02/P04/P07/P08 browser gaps from docs/account-journeys.md:
clear login-name versus display-name and lifecycle states, delivery confirmation,
consolidated onboarding diagnosis, scoped lifecycle confirmation/readback, admin
succession guard review and safe audit/support recovery. Existing headless routes
are starting points, not browser acceptance. Split into owner workplans as needed.
## Validate all journeys across roles and applications
```task
id: USER-WP-0027-T06
status: todo
priority: high
```
Execute success/failure/recovery matrix with synthetic user, tenant admin,
platform operator, expired and wrong-tenant identities. Include invitation/mail,
actual OTP, shared sign-out, keyboard/mobile, form preservation, and authoritative
application access (USER-WP-0026-T03). Preserve VERGABE-WP-0019 real-user acceptance.
Record live evidence and hand off any residuals as live work before closing.