Add account recovery, visible access records and shared sign-out handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 1m21s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-12 10:34:44 +02:00
parent 7330c25d80
commit e54b6ee970
5 changed files with 169 additions and 13 deletions

View file

@ -71,3 +71,20 @@ Use `user_engine.testing.scenarios` for human, tenant admin, platform
operator, delegated agent, invalid, expired, local issuer, and missing-tenant operator, delegated agent, invalid, expired, local issuer, and missing-tenant
fixtures. UIs should keep fixtures at the transport boundary and avoid fixtures. UIs should keep fixtures at the transport boundary and avoid
embedding identity-provider logic. embedding identity-provider logic.
## Account recovery
`/access-recovery` is public and never starts OIDC automatically. It distinguishes
an unverified visitor from a verified current portal session, and exposes account
and logout controls. Browser errors strip callback query parameters before
reaching it. `/onboarding` shows the verified identity, sign-in tenant and roles,
personal tenant memberships and recorded application/service/workload/asset
memberships. These are the current user's records, not a fleet-wide entitlement
inventory or a grant to every application in a tenant. No workload record means
unknown/unrecorded access, not an inferred denial or grant.
POST `/logout` keeps its CSRF-protected local logout. With `scope=shared` it clears
the portal session and sends the browser to the configured issuer's
`/account/logout` confirmation, followed by provider-owned sign-out. No supplied
return URL is accepted. Other RP sessions and already issued JWTs may remain
valid. KEY-WP-0034 owns issuer confirmation; USER-WP-0026 tracks this recovery.

View file

@ -89,7 +89,11 @@ class OIDCClient:
with urlopen(request, timeout=10) as response: with urlopen(request, timeout=10) as response:
tokens = json.loads(response.read()) tokens = json.loads(response.read())
token = str(tokens.get("id_token") or tokens.get("access_token") or "") token = str(tokens.get("id_token") or tokens.get("access_token") or "")
claims = self._verify(token) import jwt
try:
claims = self._verify(token)
except jwt.PyJWTError:
raise ValueError("OIDC token verification failed") from None
session_id = secrets.token_urlsafe(32) session_id = secrets.token_urlsafe(32)
expiry = min(float(claims.get("exp", time.time() + self.session_ttl)), time.time() + self.session_ttl) expiry = min(float(claims.get("exp", time.time() + self.session_ttl)), time.time() + self.session_ttl)
self.sessions[session_id] = BrowserSession( self.sessions[session_id] = BrowserSession(

View file

@ -22,6 +22,7 @@ from collections import deque
from threading import Lock from threading import Lock
from time import monotonic from time import monotonic
from typing import Any, Callable, Iterable, Mapping from typing import Any, Callable, Iterable, Mapping
from urllib.error import URLError
from urllib.parse import parse_qs, quote, unquote, urlencode, urlsplit from urllib.parse import parse_qs, quote, unquote, urlencode, urlsplit
from user_engine.domain import ( from user_engine.domain import (
@ -175,12 +176,16 @@ class PortalApplication:
if self.oidc_client is None: if self.oidc_client is None:
raise NotFoundError("OIDC login is not configured") raise NotFoundError("OIDC login is not configured")
query = parse_qs(str(environ.get("QUERY_STRING", ""))) query = parse_qs(str(environ.get("QUERY_STRING", "")))
if query.get("error"): try:
raise AuthorizationDenied("OIDC login failed") if query.get("error"):
session_id = self.oidc_client.complete( self.oidc_client.pending.pop(query.get("state", [""])[0], None)
code=query.get("code", [""])[0], raise ValueError("OIDC login failed")
state=query.get("state", [""])[0], session_id = self.oidc_client.complete(
) code=query.get("code", [""])[0],
state=query.get("state", [""])[0],
)
except (ValueError, URLError, OSError):
return self._redirect(start_response, "/access-recovery", correlation_id)
headers = [ headers = [
("Location", "/"), ("Location", "/"),
("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"), ("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"),
@ -188,6 +193,24 @@ class PortalApplication:
] ]
start_response("303 See Other", headers) start_response("303 See Other", headers)
return [b""] return [b""]
if path == "/access-recovery" and method == "GET":
try:
actor = self._optional_actor(environ)
except AuthorizationDenied:
actor = None
self._set_account_navigation(environ, actor)
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>'
)
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>'
+ identity
+ '<p><a href="/login">Verify my current identity</a></p>'
'<p><a href="/logout">Log out or use another account</a></p>',
), 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)
@ -195,8 +218,8 @@ class PortalApplication:
"Logged out", "Logged out",
'<h1>You have logged out.</h1>' '<h1>You have logged out.</h1>'
'<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>' '<p>Your portal session has ended. Your shared NetKingdom sign-in may still be active.</p>'
'<p>To sign in as another account while keeping that session, open this portal in a private browser window.</p>' + self._shared_logout_link()
'<p><a class="button" href="/login">Sign in</a></p>', + '<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)
@ -209,17 +232,18 @@ class PortalApplication:
'<p>This ends your portal session. Your shared NetKingdom sign-in stays active.</p>' '<p>This ends your portal session. Your shared NetKingdom sign-in stays active.</p>'
'<form method="post" action="/logout">' '<form method="post" action="/logout">'
f'<input type="hidden" name="csrf_token" value="{escape(token)}">' f'<input type="hidden" name="csrf_token" value="{escape(token)}">'
'<button type="submit">Log out</button></form>', '<button type="submit">Log out of this portal</button>'
'<button type="submit" name="scope" value="shared">Continue to NetKingdom sign-out</button></form>',
), correlation_id) ), correlation_id)
if path == "/logout" and method == "POST": if path == "/logout" and method == "POST":
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session") session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
body = self._form_body(environ)
if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None: if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None:
body = self._form_body(environ)
self._require_csrf(environ, str(body.get("csrf_token", ""))) self._require_csrf(environ, str(body.get("csrf_token", "")))
self.oidc_client.logout(session_id) self.oidc_client.logout(session_id)
start_response( start_response(
"303 See Other", "303 See Other",
[("Location", "/logged-out"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)], [("Location", self.oidc_client.issuer + "/account/logout" if body.get("scope") == "shared" and self.oidc_client else "/logged-out"), ("Set-Cookie", "ue_session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0"), *self._security_headers(correlation_id)],
) )
return [b""] return [b""]
if path == "/" and method == "GET": if path == "/" and method == "GET":
@ -1520,6 +1544,14 @@ 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:
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>'
)
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>"
@ -1810,7 +1842,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">Email and sign-in</h2><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>Passwords and MFA are managed by your identity provider.</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>
@ -1818,9 +1850,18 @@ class PortalApplication:
<button type="submit">Save profile</button></form></section> <button type="submit">Save profile</button></form></section>
<section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul> <section aria-labelledby="tenants"><h2 id="tenants">Tenant access</h2><p>Viewing <strong>{escape(selected_tenant)}</strong>.</p><ul>{membership_items}</ul>
<p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section> <p><a href="/login?{urlencode({'tenant_hint': selected_tenant})}">Reauthenticate in this tenant</a> to change the authoritative login context.</p></section>
<section aria-labelledby="workloads"><h2 id="workloads">Workload access</h2>{self._workload_memberships(memberships)}<p>Each application checks access when you open it. Tenant membership alone does not grant access to every application.</p></section>
<section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""", <section aria-labelledby="steps"><h2 id="steps">Onboarding progress</h2><ul>{journey_items}</ul></section>""",
) )
@staticmethod
def _workload_memberships(memberships: tuple[Any, ...]) -> str:
items = "".join(
f"<li>{escape(item.scope_id)}{escape(item.kind)} ({escape(item.tenant)})</li>"
for item in memberships if item.scope_type in {"application", "service", "workload", "asset"}
)
return "<ul>" + items + "</ul>" if items else "<p>No workload-specific access is recorded for this account.</p>"
@staticmethod @staticmethod
def _onboarding_journey_item(journey: Any, csrf_token: str) -> str: def _onboarding_journey_item(journey: Any, csrf_token: str) -> str:
steps = "".join( steps = "".join(

View file

@ -0,0 +1,48 @@
import unittest
import test_portal_navigation
from test_web import invoke
class AccountRecoveryTests(unittest.TestCase):
setUp = test_portal_navigation.PortalNavigationTests.setUp
get = test_portal_navigation.PortalNavigationTests.get
def test_recovery_is_public_and_never_trusts_query_identity(self):
response, body = invoke(self.app, '/access-recovery', query='username=forged&code=private&next=https://evil.example')
self.assertEqual('200 OK', response['status'])
for marker in [b'forged', b'private', b'evil.example']:
self.assertNotIn(marker, body)
self.assertIn(b'/logout', body)
self.assertNotIn('Location', response['headers'])
def test_signed_in_recovery_has_verified_identity_and_access_link(self):
response, body = self.get('/access-recovery')
self.assertEqual('200 OK', response['status'])
self.assertIn(b'/onboarding', body)
self.assertIn(b'This portal is signed in as', body)
_, body = self.get('/onboarding')
self.assertIn(b'Current identity', body)
self.assertIn(b'Workload access', body)
self.assertIn(b'No workload-specific access is recorded', body)
def test_shared_logout_clears_portal_then_uses_provider_confirmation(self):
response, _ = invoke(self.app, '/logout', method='POST', cookie='ue_session=operator', form={'csrf_token':'wrong','scope':'shared'})
self.assertEqual('403 Forbidden', response['status'])
self.assertIsNotNone(self.oidc.claims('operator'))
response, _ = invoke(self.app, '/logout', method='POST', cookie='ue_session=operator', form={'csrf_token':'operator-csrf','scope':'shared','return':'https://evil.example'})
self.assertEqual('https://kc.example/account/logout', response['headers']['Location'])
self.assertIsNone(self.oidc.claims('operator'))
self.assertIsNotNone(self.oidc.claims('member'))
_, body = invoke(self.app, '/logged-out')
self.assertIn(b'https://kc.example/account/logout',body)
def test_failed_callback_has_clean_recovery_and_no_loop(self):
response, _ = invoke(self.app, '/oidc/callback', query='error=access_denied&state=private&code=private')
self.assertEqual('/access-recovery', response['headers']['Location'])
def test_account_workload_list_is_scoped_to_current_user(self):
from user_engine.domain import Membership
session = self.app.service.me(self.oidc.claims('operator'), correlation_id='synthetic')
for user, scope, label in [(session.user.user_id,'service','own-workload'),('someone-else','service','private-workload')]:
self.app.service.store.save_membership(Membership(membership_id=label, user_id=user, tenant='tenant:platform:root', scope_type=scope, scope_id=label, kind='user'))
_, body = self.get('/onboarding')
self.assertIn(b'own-workload', body)
self.assertNotIn(b'private-workload', body)

View file

@ -0,0 +1,46 @@
---
id: USER-WP-0026
type: workplan
title: "Account recovery and visible identity and access"
domain: communication
repo: user-engine
status: active
owner: codex
topic_slug: user-engine
created: "2026-09-12"
updated: "2026-09-12"
---
The operator reports a dead-end authentication error after using an account
outside the product tenant. Recent issuer telemetry indicates token exchange
failure; tenant rejection and provider failure must not be conflated.
## Implement and validate recovery
```task
id: USER-WP-0026-T01
status: done
priority: high
```
Route failed browser login to the public account recovery surface without codes,
state or unverified identity. Show verified portal identity, tenant memberships,
and recorded workload memberships; preserve operator/customer separation.
Provide CSRF-protected portal logout and confirmed shared provider sign-out with
fixed owner-configured return locations. No automatic reauthentication loops,
MFA downgrade, global JWT revocation claim or inferred workload entitlements.
## Publish and verify the recovery flow
```task
id: USER-WP-0026-T02
status: progress
priority: high
```
Publish immutable images, update canonical runtime pins, verify anonymous
recovery and sign-out confirmation live, and record actual account switching
only after browser evidence. Existing application sessions may outlive provider
logout. Related: USER-WP-0025-T03 and VERGABE-WP-0019-T06.
Source verification: 182 tests passed with three optional integration skips; layer conformance passed. Immutable publication and live checks are in progress.