Clarify account session controls and add authenticator recovery guidance
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
61dc76739f
commit
a9ff77d21d
8 changed files with 395 additions and 26 deletions
112
docs/account-journeys.md
Normal file
112
docs/account-journeys.md
Normal 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.
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'<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>'
|
||||
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(
|
||||
"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
|
||||
+ '<p><a href="/login">Verify my current identity</a></p>'
|
||||
'<p><a href="/logout">Log out or use another account</a></p>',
|
||||
+ '<p><a href="/security">Help with passwords and verification codes</a></p>'
|
||||
+ (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",
|
||||
'<h1>You have logged out.</h1>'
|
||||
'<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>'
|
||||
+ self._shared_logout_link()
|
||||
+ '<p><a class="button" href="/login">Sign in</a></p>',
|
||||
"Not signed in",
|
||||
'<h1>You are not signed in to this portal.</h1>'
|
||||
'<p>Your shared NetKingdom sign-in may still be active. Signing in may reuse that account.</p>'
|
||||
+ 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'<p><a class="button" href="{escape(self.oidc_client.issuer)}/account/logout">'
|
||||
'Sign out of NetKingdom to use another account</a></p>'
|
||||
'<details><summary>Wrong account appears when signing in?</summary>'
|
||||
'<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:
|
||||
identity = (
|
||||
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
|
||||
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>'
|
||||
if self.public_registration and self.registration_verification is not None
|
||||
|
|
@ -1842,7 +1899,7 @@ class PortalApplication:
|
|||
return self._page_html(
|
||||
"Onboarding",
|
||||
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>
|
||||
<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>
|
||||
|
|
@ -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('<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
|
||||
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:
|
||||
links += '<a href="/platform">Platform administration</a>'
|
||||
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 += (
|
||||
'<form method="post" action="/logout">'
|
||||
f'<input type="hidden" name="csrf_token" value="{escape(csrf)}">'
|
||||
'<button type="submit">Log out</button></form>'
|
||||
)
|
||||
_ACCOUNT_NAVIGATION.set('<nav aria-label="Account navigation">' + links + '</nav>')
|
||||
links += '<a href="/logout">Log out</a>'
|
||||
identity = escape(actor.preferred_username or actor.subject)
|
||||
_ACCOUNT_NAVIGATION.set(f'<p>Signed in to this portal as <strong>{identity}</strong></p><nav aria-label="Account navigation">' + links + '</nav>')
|
||||
|
||||
@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'<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)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
77
tests/test_account_clarity.py
Normal file
77
tests/test_account_clarity.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"<main>", html)
|
||||
|
|
|
|||
97
workplans/USER-WP-0027-account-journey-clarity.md
Normal file
97
workplans/USER-WP-0027-account-journey-clarity.md
Normal 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 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue