diff --git a/docs/ui-contracts.md b/docs/ui-contracts.md index 545f03b..add9353 100644 --- a/docs/ui-contracts.md +++ b/docs/ui-contracts.md @@ -71,3 +71,20 @@ Use `user_engine.testing.scenarios` for human, tenant admin, platform operator, delegated agent, invalid, expired, local issuer, and missing-tenant fixtures. UIs should keep fixtures at the transport boundary and avoid 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. diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py index 53c920d..45efffe 100644 --- a/src/user_engine/oidc.py +++ b/src/user_engine/oidc.py @@ -89,7 +89,11 @@ class OIDCClient: with urlopen(request, timeout=10) as response: tokens = json.loads(response.read()) 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) expiry = min(float(claims.get("exp", time.time() + self.session_ttl)), time.time() + self.session_ttl) self.sessions[session_id] = BrowserSession( diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 9c9a58d..b4c99c1 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -22,6 +22,7 @@ from collections import deque from threading import Lock from time import monotonic from typing import Any, Callable, Iterable, Mapping +from urllib.error import URLError from urllib.parse import parse_qs, quote, unquote, urlencode, urlsplit from user_engine.domain import ( @@ -175,12 +176,16 @@ class PortalApplication: if self.oidc_client is None: raise NotFoundError("OIDC login is not configured") query = parse_qs(str(environ.get("QUERY_STRING", ""))) - if query.get("error"): - raise AuthorizationDenied("OIDC login failed") - session_id = self.oidc_client.complete( - code=query.get("code", [""])[0], - state=query.get("state", [""])[0], - ) + try: + if query.get("error"): + self.oidc_client.pending.pop(query.get("state", [""])[0], None) + raise ValueError("OIDC login failed") + 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 = [ ("Location", "/"), ("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) 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'
This portal is signed in as {escape(actor.preferred_username or actor.subject)}.
' + '' + if actor else 'Your identity has not been verified in this portal.
' + ) + return self._html(start_response, self._page_html( + "Sign-in help", 'The application may not allow this account, or the sign-in service may have failed.
' + + identity + + '' + 'Log out or use another account
', + ), correlation_id) if path == "/logged-out" and method == "GET": if self._optional_actor(environ) is not None: return self._redirect(start_response, "/", correlation_id) @@ -195,8 +218,8 @@ class PortalApplication: "Logged out", 'Your portal session has ended. Your shared NetKingdom sign-in may still be active.
' - 'To sign in as another account while keeping that session, open this portal in a private browser window.
' - '', + + self._shared_logout_link() + + '', ), correlation_id) if path == "/logout" and method == "GET": actor = self._optional_actor(environ) @@ -209,17 +232,18 @@ class PortalApplication: 'This ends your portal session. Your shared NetKingdom sign-in stays active.
' '', + '' + '', ), correlation_id) if path == "/logout" and method == "POST": 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: - body = self._form_body(environ) self._require_csrf(environ, str(body.get("csrf_token", ""))) self.oidc_client.logout(session_id) start_response( "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""] if path == "/" and method == "GET": @@ -1520,6 +1544,14 @@ class PortalApplication: raise ValidationError("Idempotency-Key must contain at least 16 characters") return value + def _shared_logout_link(self) -> str: + if not self.oidc_client: + return "" + return ( + f'' + 'Sign out of NetKingdom to use another account
' + ) + def _home(self, actor: Any | None) -> str: identity = ( f"Signed in as {escape(actor.preferred_username)}.
" @@ -1810,7 +1842,7 @@ class PortalApplication: return self._page_html( "Onboarding", f"""{escape(verification)}
Passwords and MFA are managed by your identity provider.
Signed in as {escape(session.actor.preferred_username or session.actor.subject)}.
Sign-in tenant: {escape(session.actor.tenant)}.
Roles: {escape(", ".join(session.actor.roles) or "None")}.
{escape(verification)}
Passwords and MFA are managed by your identity provider.
Viewing {escape(selected_tenant)}.
Reauthenticate in this tenant to change the authoritative login context.
Each application checks access when you open it. Tenant membership alone does not grant access to every application.
No workload-specific access is recorded for this account.
" + @staticmethod def _onboarding_journey_item(journey: Any, csrf_token: str) -> str: steps = "".join( diff --git a/tests/test_account_recovery.py b/tests/test_account_recovery.py new file mode 100644 index 0000000..382a084 --- /dev/null +++ b/tests/test_account_recovery.py @@ -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) diff --git a/workplans/USER-WP-0026-account-recovery.md b/workplans/USER-WP-0026-account-recovery.md new file mode 100644 index 0000000..ba81267 --- /dev/null +++ b/workplans/USER-WP-0026-account-recovery.md @@ -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.