From a9ff77d21db5316d49d8d54519a490ca21951bc7 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 13 Sep 2026 10:09:01 +0200 Subject: [PATCH] Clarify account session controls and add authenticator recovery guidance Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c --- docs/account-journeys.md | 112 +++++++++++++++++ docs/configuration.md | 15 +++ src/user_engine/runtime.py | 1 + src/user_engine/web.py | 114 ++++++++++++++---- tests/test_account_clarity.py | 77 ++++++++++++ tests/test_portal_navigation.py | 3 +- tests/test_web.py | 2 +- .../USER-WP-0027-account-journey-clarity.md | 97 +++++++++++++++ 8 files changed, 395 insertions(+), 26 deletions(-) create mode 100644 docs/account-journeys.md create mode 100644 tests/test_account_clarity.py create mode 100644 workplans/USER-WP-0027-account-journey-clarity.md diff --git a/docs/account-journeys.md b/docs/account-journeys.md new file mode 100644 index 0000000..6bb94b4 --- /dev/null +++ b/docs/account-journeys.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 73b1c8f..af5c32b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 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). diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index d95dffa..b0d8191 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -66,6 +66,7 @@ def create_application() -> PortalApplication: service, trusted_proxy_secret=_required("USER_ENGINE_PROXY_SECRET"), 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() == "true", oidc_client=OIDCClient( diff --git a/src/user_engine/web.py b/src/user_engine/web.py index b4c99c1..5cac56b 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -48,6 +48,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) def _jsonable(value: Any) -> Any: @@ -74,6 +75,7 @@ class PortalApplication: trusted_proxy_secret: str, login_url: str, public_registration: bool = True, + mfa_management_url: str = "", oidc_client: OIDCClient | None = None, provisioning: IdentityProvisioningPort | None = None, tenant_management: TenantManagementPort | None = None, @@ -92,6 +94,14 @@ class PortalApplication: self.trusted_proxy_secret = trusted_proxy_secret self.login_url = login_url 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.provisioning = provisioning self.tenant_management = tenant_management @@ -113,7 +123,15 @@ class PortalApplication: 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("") + 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: + 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)) except ConflictError as exc: 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) finally: _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]: method = str(environ.get("REQUEST_METHOD", "GET")).upper() @@ -202,24 +221,25 @@ class PortalApplication: identity = ( f'

This portal is signed in as {escape(actor.preferred_username or actor.subject)}.

' '

View my account and access

' - if actor else '

Your identity has not been verified in this portal.

' + if actor else '

You are not signed in to this portal. Sign in to verify your identity and access.

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

Sign-in could not be completed

' - '

The application may not allow this account, or the sign-in service may have failed.

' + '

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

' + identity - + '

Verify my current identity

' - '

Log out or use another account

', + + '

Help with passwords and verification codes

' + + (self._identity_switch_help() if actor is None else ""), ), 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 self._optional_actor(environ) is not None: return self._redirect(start_response, "/", correlation_id) return self._html(start_response, self._page_html( - "Logged out", - '

You have logged out.

' - '

Your portal session has ended. Your shared NetKingdom sign-in may still be active.

' - + self._shared_logout_link() - + '

Sign in

', + "Not signed in", + '

You are not signed in to this portal.

' + '

Your shared NetKingdom sign-in may still be active. Signing in may reuse that account.

' + + self._identity_switch_help(), ), correlation_id) if path == "/logout" and method == "GET": actor = self._optional_actor(environ) @@ -252,6 +272,8 @@ class PortalApplication: return self._html(start_response, self._home(actor), correlation_id) 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: raise NotFoundError("public registration is unavailable") token = secrets.token_urlsafe(32) @@ -1544,21 +1566,56 @@ class PortalApplication: raise ValidationError("Idempotency-Key must contain at least 16 characters") return value - def _shared_logout_link(self) -> str: + def _identity_switch_help(self) -> str: if not self.oidc_client: return "" return ( - f'

' - 'Sign out of NetKingdom to use another account

' + '
Wrong account appears when signing in?' + '

You can clear the shared sign-in before choosing another account. ' + 'Other applications may keep their own sessions.

' + f'

' + 'Use another account

' ) + def _security_page(self) -> str: + handoff = ( + '

Manage authenticator app

' + '

The sign-in service will ask you to verify your identity. Check the account name there before making changes.

' + if self.mfa_management_url else + '

Authenticator setup is temporarily unavailable. ' + 'If a code is requested before you have set up an authenticator, contact your tenant administrator.

' + ) + return self._page_html("Sign-in security", """ +

Sign-in security

+

Use this page for help with your password and authenticator app.

+

Two-step verification

+

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.

+""" + handoff + """ +
How to set up an authenticator when setup is available +
  1. Open authenticator management and check that it shows your account.
  2. +
  3. Choose to add an authenticator and scan its QR code with your authenticator app.
  4. +
  5. Enter a current code to confirm setup. Wait for the sign-in service to confirm activation.
  6. +
  7. Follow the recovery instructions shown there, then test a new sign-in before closing your current session.
+

Opening the setup page does not activate two-step verification. If you cancel, check the status in authenticator management before leaving.

+
A code is rejected, or I have lost my authenticator +

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.

+

Password help

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.

+

Back to sign-in help

""") + def _home(self, actor: Any | None) -> str: identity = ( f"

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

" - '

Continue onboarding

' + '

View my account

' if actor is not None else ( - f'

Sign in with KeyCape

' + '

You are not signed in to this portal.

' + ( '

New here? Create an account.

' if self.public_registration and self.registration_verification is not None @@ -1842,7 +1899,7 @@ class PortalApplication: return self._page_html( "Onboarding", f"""

Welcome, {escape(session.user.display_name or session.actor.preferred_username or session.user.user_id)}

-

Current identity

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

Sign-in tenant: {escape(session.actor.tenant)}.

Roles: {escape(", ".join(session.actor.roles) or "None")}.

{escape(verification)}

Passwords and MFA are managed by your identity provider.

+

Current identity

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

Sign-in tenant: {escape(session.actor.tenant)}.

Roles: {escape(", ".join(session.actor.roles) or "None")}.

{escape(verification)}

Password and two-step verification help

Profile and consent

@@ -1911,9 +1968,9 @@ class PortalApplication: def _set_account_navigation(self, environ: Mapping[str, Any], actor: Any | None) -> None: if actor is None: - _ACCOUNT_NAVIGATION.set("") + _ACCOUNT_NAVIGATION.set('

Not signed in to this portal

') return - links = 'HomeMy account' + links = 'HomeMy accountSign-in security' if "platform-operator" in actor.roles: links += 'Platform administration' elif "tenant-admin" in actor.roles: @@ -1921,12 +1978,9 @@ class PortalApplication: 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 if csrf: - links += ( - '' - f'' - '
' - ) - _ACCOUNT_NAVIGATION.set('') + links += 'Log out' + identity = escape(actor.preferred_username or actor.subject) + _ACCOUNT_NAVIGATION.set(f'

Signed in to this portal as {identity}

') @staticmethod 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] 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'

{title}

{guidance}

' + '

Account and sign-in help

' + f'

If you need help, give your administrator this reference: {escape(str(correlation_id))}.

') + 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) @staticmethod diff --git a/tests/test_account_clarity.py b/tests/test_account_clarity.py new file mode 100644 index 0000000..8af3604 --- /dev/null +++ b/tests/test_account_clarity.py @@ -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'
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', body) + for value in [b'forged', b'evil.example', b'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) diff --git a/tests/test_portal_navigation.py b/tests/test_portal_navigation.py index 4e5d496..569c21a 100644 --- a/tests/test_portal_navigation.py +++ b/tests/test_portal_navigation.py @@ -39,8 +39,7 @@ class PortalNavigationTests(unittest.TestCase): response, body = self.get(path) self.assertEqual('200 OK', response['status'], path) self.assertIn(b'href="/platform"', body) - self.assertIn(b'action="/logout"', body) - self.assertIn(b'value="operator-csrf"', body) + self.assertIn(b'href="/logout"', body) _, body = self.get('/onboarding') self.assertIn(b'no personal tenant memberships', body) self.assertIn(b'platform operator role', body) diff --git a/tests/test_web.py b/tests/test_web.py index 6c0bf55..0e492e1 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -101,7 +101,7 @@ class PortalApplicationTests(unittest.TestCase): self.assertEqual("ok", json.loads(payload)["status"]) home, html = invoke(self.app, "/") 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"focus-visible", html) self.assertIn(b"
", html) diff --git a/workplans/USER-WP-0027-account-journey-clarity.md b/workplans/USER-WP-0027-account-journey-clarity.md new file mode 100644 index 0000000..dd77576 --- /dev/null +++ b/workplans/USER-WP-0027-account-journey-clarity.md @@ -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 U05–U08/P04–P06. 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 T02–T08/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.