From 58e07dd4df7686b7df7148eaccf30be9ca6b4339 Mon Sep 17 00:00:00 2001
From: tegwick
Date: Sun, 13 Sep 2026 21:05:33 +0200
Subject: [PATCH] Connect P04 audited recovery to fresh-MFA platform browser
flow
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
---
scripts/browser_journeys.mjs | 9 +++
scripts/browser_journeys.py | 3 +-
src/user_engine/factor_recovery.py | 92 +++++++++++++++++++++++++++
src/user_engine/oidc.py | 11 +++-
src/user_engine/runtime.py | 3 +
src/user_engine/web.py | 38 +++++++++--
tests/test_factor_recovery_journey.py | 52 +++++++++++++++
7 files changed, 201 insertions(+), 7 deletions(-)
create mode 100644 src/user_engine/factor_recovery.py
create mode 100644 tests/test_factor_recovery_journey.py
diff --git a/scripts/browser_journeys.mjs b/scripts/browser_journeys.mjs
index 5d95c76..bd968fd 100644
--- a/scripts/browser_journeys.mjs
+++ b/scripts/browser_journeys.mjs
@@ -51,6 +51,15 @@ try{
await evaluate(`Array.from(document.forms).find(f=>f.action.endsWith("/recover")).querySelector("button").click()`);
await waitFor('document.body.innerText.includes("Confirm change")');
await check(`document.body.innerText.includes("does not reset a password") && document.body.innerText.includes("cannot bypass")`,'P04 restoration explains factor boundary');
+ await identity('admin');await navigate('/platform/factor-recovery');
+ await check(`document.body.innerText.includes("Access is not available")`,'P04 tenant admin cannot recover shared factor');
+ await identity('operator');await navigate('/platform/factor-recovery');
+ await evaluate(`document.querySelector('input[name="user"]').value='alice';document.querySelector('input[name="reference"]').value='case-1';document.querySelector('form button').click()`);
+ await waitFor('document.body.innerText.includes("Review the selected authenticator")');
+ await check(`document.body.innerText.includes("all applications") && !!document.querySelector('input[name="identity_verified"][required]')`,'P04 scope and identity verification before mutation');
+ await evaluate(`document.querySelector('input[name="identity_verified"]').checked=true;Array.from(document.forms).find(f=>f.querySelector('input[name="confirmation"]')).querySelector('button').click()`);
+ await waitFor('document.body.innerText.includes("Authenticator recovery recorded")');
+ await check(`document.body.innerText.includes("case-1") && document.body.innerText.includes("Enroll a replacement") && !document.body.innerText.includes("server-only-token")`,'P04 receipt and replacement onboarding without credentials');
await navigate('/logout');
await check(`document.body.innerText.includes("Log out of this portal?")`,'U11 logout requires confirmation');
await evaluate(`document.querySelector('form[action="/logout"] button').click()`);
diff --git a/scripts/browser_journeys.py b/scripts/browser_journeys.py
index 381e6c8..797d626 100644
--- a/scripts/browser_journeys.py
+++ b/scripts/browser_journeys.py
@@ -13,6 +13,7 @@ from wsgiref.simple_server import make_server, WSGIRequestHandler
ROOT=Path(__file__).resolve().parents[1]
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')]
from test_journey_roles import JourneyFixture
+from test_factor_recovery_journey import FactorRecoveryJourney
chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome')
if not chrome:
@@ -20,7 +21,7 @@ if not chrome:
chrome=str(matches[-1]) if matches else None
if not chrome or not shutil.which('node'):
raise SystemExit('Chromium and Node are required; set JOURNEY_CHROME to the Chromium executable. Browser tests were not run.')
-fixture=JourneyFixture();fixture.setUp();fixture.member(email='actual.login@example.test')
+fixture=FactorRecoveryJourney();fixture.setUp();fixture.member(email='actual.login@example.test')
class Quiet(WSGIRequestHandler):
def log_message(self,*args):pass
server=make_server('127.0.0.1',0,fixture.app,handler_class=Quiet)
diff --git a/src/user_engine/factor_recovery.py b/src/user_engine/factor_recovery.py
new file mode 100644
index 0000000..b46ab4e
--- /dev/null
+++ b/src/user_engine/factor_recovery.py
@@ -0,0 +1,92 @@
+"""Recovery transport and server-rendered operator journey."""
+import json
+import time
+from html import escape
+from urllib.error import HTTPError
+from urllib.request import Request, build_opener, HTTPRedirectHandler
+
+class NoRedirect(HTTPRedirectHandler):
+ def redirect_request(self, *args, **kwargs):
+ return None
+
+class FactorRecoveryClient:
+ def __init__(self, url):
+ from urllib.parse import urlsplit
+ parsed = urlsplit(url)
+ if parsed.scheme not in {'http','https'} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
+ raise ValueError('invalid recovery endpoint')
+ self.url = url.rstrip('/') + '/recover'
+
+ def call(self, token, body):
+ request = Request(self.url, data=json.dumps(body).encode(), method='POST',
+ headers={'Content-Type':'application/json','Authorization':'Bearer '+token})
+ try:
+ response = build_opener(NoRedirect).open(request, timeout=15)
+ except HTTPError as exc:
+ response = exc
+ with response:
+ data = response.read(65537)
+ if len(data)>65536:
+ raise RuntimeError('recovery unavailable')
+ result = json.loads(data)
+ if not isinstance(result,dict) or not isinstance(result.get('success'),bool):
+ raise RuntimeError('recovery unavailable')
+ return result
+
+
+def fresh(claims):
+ assurance=claims.get('assurance',{})
+ return (isinstance(assurance,dict) and assurance.get('level')=='aal2'
+ and assurance.get('mfa') is True
+ and type(assurance.get('at')) in (int,float)
+ and 0 <= time.time()-assurance['at'] <= 300)
+
+
+def page(csrf, result=None, submitted=None, management_url=''):
+ submitted=submitted or {}
+ result=result or {}
+ def hidden(name,value):
+ return f''
+ common=hidden('csrf_token',csrf)
+ html='
Recover a lost authenticator
Use this only after verifying the person through your established account-recovery process. A username or password alone is not sufficient proof.
'
+ html+='
This changes a shared sign-in identity across all its applications and tenants. Tenant access, the password and other authenticators are retained.
'
+ failure=result.get('failure')
+ messages={
+ 'fresh_platform_mfa_required':'Sign in again with MFA before continuing.',
+ 'identity_verification_required':'Confirm that you verified the person before applying recovery.',
+ 'preview_expired_or_changed':'The confirmation expired or changed. Create a new preview.',
+ 'stale_preview':'The authenticator changed after preview. Create a new preview.',
+ 'factor_changed_after_recovery':'The authenticator has changed since this recovery. Check the current state.',
+ 'factor_not_owned_by_target':'That authenticator no longer belongs to this login. Check the target.',
+ 'reference_conflict':'This support reference belongs to another recovery. Check the recorded operation.',
+ 'invalid_request':'Enter a valid login and support reference.',
+ }
+ if failure:
+ message=messages.get(failure,'Recovery could not be confirmed. A change may have occurred. Retry the same confirmation to reconcile the result, or check the support reference. Do not start another recovery blindly.')
+ html+='
'+escape(message)+'
'
+ if failure=='fresh_platform_mfa_required':
+ html+='
Support reference: '+escape(result['reference'])+'. The provider audit confirms this operation.'
+ if result.get('replayed'):html+=' This retry confirmed the earlier change without repeating it.'
+ html+='
Return control to the user
Have the user sign in to authenticator management with their own password.
Enroll a replacement authenticator and prove possession with a generated code.
Test a fresh application sign-in with the new code before closing the support case.
Applications requiring MFA remain inaccessible until a working factor is available. Never send passwords, setup QR codes or verification codes to the support case.
'
+ return html
+
+
+def apply_form(common, confirmation, label='Disable this lost authenticator'):
+ return ('')
diff --git a/src/user_engine/oidc.py b/src/user_engine/oidc.py
index 45efffe..43e03e6 100644
--- a/src/user_engine/oidc.py
+++ b/src/user_engine/oidc.py
@@ -17,6 +17,7 @@ from urllib.request import Request, urlopen
class PendingLogin:
verifier: str
created_at: float
+ recovery: bool = False
@dataclass
@@ -24,6 +25,8 @@ class BrowserSession:
claims: Mapping[str, Any]
expires_at: float
csrf_token: str = ""
+ id_token: str = ""
+ recovery: bool = False
class OIDCClient:
@@ -48,11 +51,11 @@ class OIDCClient:
self.pending: dict[str, PendingLogin] = {}
self.sessions: dict[str, BrowserSession] = {}
- def begin(self, *, tenant_hint: str | None = None) -> str:
+ def begin(self, *, tenant_hint: str | None = None, recovery: bool = False) -> str:
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
challenge = _b64(hashlib.sha256(verifier.encode("ascii")).digest())
- self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time())
+ self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time(), recovery=recovery)
self._prune()
parameters = {
'response_type': 'code',
@@ -63,6 +66,8 @@ class OIDCClient:
'code_challenge': challenge,
'code_challenge_method': 'S256',
}
+ if recovery:
+ parameters.update(prompt="login", max_age="0", acr_values="aal2")
if tenant_hint:
parameters["tenant_hint"] = tenant_hint
return f"{self.issuer}/authorize?{urlencode(parameters)}"
@@ -100,6 +105,8 @@ class OIDCClient:
claims=claims,
expires_at=expiry,
csrf_token=secrets.token_urlsafe(32),
+ id_token=token,
+ recovery=pending.recovery,
)
self._prune()
return session_id
diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py
index b0d8191..68279b2 100644
--- a/src/user_engine/runtime.py
+++ b/src/user_engine/runtime.py
@@ -17,6 +17,7 @@ from user_engine.adapters import (
from user_engine.service import UserEngineService
from user_engine.oidc import OIDCClient
from user_engine.web import PortalApplication
+from user_engine.factor_recovery import FactorRecoveryClient
def create_application() -> PortalApplication:
@@ -76,6 +77,8 @@ def create_application() -> PortalApplication:
audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
backend_url=os.environ.get("USER_ENGINE_OIDC_BACKEND_URL"),
),
+ factor_recovery=(FactorRecoveryClient(os.environ["USER_ENGINE_FACTOR_RECOVERY_URL"])
+ if os.environ.get("USER_ENGINE_FACTOR_RECOVERY_URL") else None),
provisioning=HTTPIdentityProvisioningAdapter(
base_url=_required("USER_ENGINE_PROVISIONING_URL"),
bearer_token=_required("USER_ENGINE_PROVISIONING_TOKEN"),
diff --git a/src/user_engine/web.py b/src/user_engine/web.py
index d2862f2..f1af186 100644
--- a/src/user_engine/web.py
+++ b/src/user_engine/web.py
@@ -78,6 +78,7 @@ class PortalApplication:
public_registration: bool = True,
mfa_management_url: str = "",
oidc_client: OIDCClient | None = None,
+ factor_recovery: Any = None,
provisioning: IdentityProvisioningPort | None = None,
tenant_management: TenantManagementPort | None = None,
outbox_delivery: Callable[[Any], None] | None = None,
@@ -104,6 +105,7 @@ class PortalApplication:
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.factor_recovery = factor_recovery
self.provisioning = provisioning
self.tenant_management = tenant_management
self.outbox_delivery = outbox_delivery
@@ -187,7 +189,7 @@ class PortalApplication:
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
raise ValidationError("tenant_hint must be a tenant identifier")
location = (
- self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None)
+ self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None, **({"recovery": True} if query.get("recovery") == ["1"] else {}))
if self.oidc_client else self.login_url
)
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
@@ -207,7 +209,7 @@ class PortalApplication:
except (ValueError, URLError, OSError):
return self._redirect(start_response, "/access-recovery", correlation_id)
headers = [
- ("Location", "/"),
+ ("Location", "/platform/factor-recovery" if self.oidc_client.sessions[session_id].recovery else "/"),
("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"),
*self._security_headers(correlation_id),
]
@@ -753,6 +755,34 @@ class PortalApplication:
"status": "removed", "tenant_account": _jsonable(account),
"provider_identity_removed": False,
}, correlation_id)
+ if path == "/platform/factor-recovery" and method in {"GET", "POST"}:
+ from user_engine.factor_recovery import fresh, page
+ actor = self._actor(environ)
+ if "platform-operator" not in actor.roles:
+ raise AuthorizationDenied("platform operator required")
+ csrf = self._csrf_token(environ)
+ submitted = self._form_body(environ) if method == "POST" else {}
+ if method == "POST":
+ self._require_csrf(environ, submitted.get("csrf_token", ""))
+ session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
+ session = self.oidc_client.sessions.get(session_id or "")
+ result = None
+ if not session or not session.id_token or not fresh(session.claims):
+ result = {"failure": "fresh_platform_mfa_required"}
+ elif self.factor_recovery is None:
+ result = {"failure": "recovery_unavailable"}
+ elif method == "POST":
+ try:
+ result = self.factor_recovery.call(session.id_token, {
+ "action": submitted.get("action"), "user": submitted.get("user", ""),
+ "reference": submitted.get("reference", ""),
+ "confirmation": submitted.get("confirmation", ""),
+ "identity_verified": submitted.get("identity_verified") == "yes",
+ })
+ except Exception:
+ result = {"failure": "recovery_unavailable"}
+ return self._html(start_response, self._page_html("Authenticator recovery",
+ page(csrf, result, submitted, self.mfa_management_url)), correlation_id)
if path == "/platform/activity":
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
if method != "GET":
@@ -1232,7 +1262,7 @@ class PortalApplication:
rows = "".join(f'
{escape(name)}
{"Configured; live health unverified" if configured else "Unavailable in this portal"}
{escape(help_text)}
'
for name, configured, help_text in capabilities)
return ('
Service capabilities
Service
Known state
Recovery step
'
- + rows + '
Authenticator recovery and authentication policy changes are unavailable in this portal. The sign-in service owner must verify factor lookup, recovery and policy enforcement. A configured adapter is not a health check.
Authenticator recovery is unavailable in this portal.
') + '
Other authentication policy changes are unavailable in this portal. A configured adapter is not a health check.
')
def _require_setup_access(self, tenant: str, user_id: str) -> None:
account = self.service.store.tenant_account(tenant, user_id)
@@ -2173,7 +2203,7 @@ Use the login name they provide; it may differ from your display name.
HomeMy accountSign-in security'
if "platform-operator" in actor.roles:
- links += 'Platform administrationService recoveryPlatform activity'
+ links += 'Authenticator recoveryPlatform administrationService recoveryPlatform activity'
elif "tenant-admin" in actor.roles:
links += f'Manage users'
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
diff --git a/tests/test_factor_recovery_journey.py b/tests/test_factor_recovery_journey.py
new file mode 100644
index 0000000..1939840
--- /dev/null
+++ b/tests/test_factor_recovery_journey.py
@@ -0,0 +1,52 @@
+import re
+import time
+from html import unescape
+from dataclasses import replace
+from test_journey_roles import JourneyFixture
+from test_web import invoke
+
+class FakeRecovery:
+ def __init__(self):self.calls=[];self.applied=False;self.outage=False
+ def call(self,token,body):
+ self.calls.append((token,body))
+ if self.outage:raise RuntimeError('private-secret')
+ if body['action']=='preview':
+ return dict(success=True,status='preview',factors=[dict(user='alice',serial='T1',reference='case-1',confirmation='signed-preview')])
+ if not body['identity_verified']:return dict(success=False,failure='identity_verification_required')
+ replay=self.applied;self.applied=True
+ return dict(success=True,status='recovered',user='alice',serial='T1',reference='case-1',replayed=replay)
+
+class FactorRecoveryJourney(JourneyFixture):
+ def setUp(self):
+ super().setUp();self.provider=FakeRecovery();self.app.factor_recovery=self.provider
+ s=self.oidc.sessions['operator']
+ self.oidc.sessions['operator']=replace(s,id_token='server-only-token',claims=dict(s.claims,assurance=dict(level='aal2',mfa=True,at=time.time())))
+ def test_role_csrf_and_freshness_denied_before_provider(self):
+ for who in ['member','admin']:
+ response,_=self.post('/platform/factor-recovery',who=who,action='preview')
+ self.assertEqual('403 Forbidden',response['status'])
+ response,_=self.post('/platform/factor-recovery',who='operator',csrf_token='bad',action='preview')
+ self.assertEqual('403 Forbidden',response['status'])
+ self.oidc.sessions['operator'].claims['assurance']['at']=time.time()-301
+ response,body=self.post('/platform/factor-recovery',who='operator',action='preview')
+ self.assertIn(b'Verify my sign-in again',body);self.assertEqual([],self.provider.calls)
+ def test_preview_cancel_apply_retry_and_secret_not_rendered(self):
+ response,body=self.post('/platform/factor-recovery',who='operator',action='preview',user='alice',reference='case-1')
+ self.assertIn(b'all applications',body);self.assertIn(b'Cancel',body);self.assertFalse(self.provider.applied)
+ self.assertNotIn(b'server-only-token',body)
+ _,body=self.post('/platform/factor-recovery',who='operator',action='apply',confirmation='signed-preview')
+ self.assertIn(b'Confirm that you verified',body);self.assertFalse(self.provider.applied)
+ self.provider.outage=True
+ _,body=self.post('/platform/factor-recovery',who='operator',action='apply',confirmation='signed-preview',identity_verified='yes')
+ self.assertIn(b'Retry this recovery',body);self.assertNotIn(b'private-secret',body)
+ self.provider.outage=False
+ for i in range(2):
+ _,body=self.post('/platform/factor-recovery',who='operator',action='apply',confirmation='signed-preview',identity_verified='yes')
+ self.assertIn(b'Authenticator recovery recorded',body)
+ self.assertIn(b'Enroll a replacement',body)
+ self.assertIn(b'without repeating it',body)
+ def test_stepup_requests_fresh_mfa_and_binds_return(self):
+ response,_=invoke(self.app,'/login',query='recovery=1')
+ location=dict(response['headers'])['Location']
+ self.assertIn('prompt=login',location);self.assertIn('max_age=0',location);self.assertIn('acr_values=aal2',location)
+ self.assertTrue(next(iter(self.oidc.pending.values())).recovery)