Add account recovery, visible access records and shared sign-out handoff
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
7330c25d80
commit
e54b6ee970
5 changed files with 169 additions and 13 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 "")
|
||||||
|
import jwt
|
||||||
|
try:
|
||||||
claims = self._verify(token)
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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", "")))
|
||||||
|
try:
|
||||||
if query.get("error"):
|
if query.get("error"):
|
||||||
raise AuthorizationDenied("OIDC login failed")
|
self.oidc_client.pending.pop(query.get("state", [""])[0], None)
|
||||||
|
raise ValueError("OIDC login failed")
|
||||||
session_id = self.oidc_client.complete(
|
session_id = self.oidc_client.complete(
|
||||||
code=query.get("code", [""])[0],
|
code=query.get("code", [""])[0],
|
||||||
state=query.get("state", [""])[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")
|
||||||
if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None:
|
|
||||||
body = self._form_body(environ)
|
body = self._form_body(environ)
|
||||||
|
if session_id and self.oidc_client and self.oidc_client.claims(session_id) is not None:
|
||||||
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(
|
||||||
|
|
|
||||||
48
tests/test_account_recovery.py
Normal file
48
tests/test_account_recovery.py
Normal 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)
|
||||||
46
workplans/USER-WP-0026-account-recovery.md
Normal file
46
workplans/USER-WP-0026-account-recovery.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue