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
|
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).
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
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