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+='

Verify my sign-in again

' + if result.get('status')=='recovered': + html+='

Authenticator recovery recorded

Selected authenticator disabled. Login: '+escape(result['user'])+'; authenticator: '+escape(result['serial'])+'.

' + 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

  1. Have the user sign in to authenticator management with their own password.
  2. Enroll a replacement authenticator and prove possession with a generated code.
  3. 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.

' + if management_url:html+='

Open authenticator management

' + factors=result.get('factors',[]) + if result.get('status')=='preview' and not factors: + html+='

No active authenticators were found for this login. Check the spelling and account. No change was made.

' + for factor in factors: + html+='

Review the selected authenticator

Login: '+escape(factor['user'])+'; authenticator: '+escape(factor['serial'])+'.

Support reference: '+escape(factor['reference'])+'. Scope: all applications using this shared identity.

' + html+=apply_form(common,factor['confirmation'])+'
' + if failure and submitted.get('confirmation'): + html+=apply_form(common,submitted['confirmation'],'Retry this recovery') + html+='
'+common+hidden('action','preview')+'
' + html+='

Cancel and return to platform administration ยท Investigate support activity

' + return html + + +def apply_form(common, confirmation, label='Disable this lost authenticator'): + return ('
'+common + +'' + +'' + +'
') 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

' - + rows + '
ServiceKnown stateRecovery step

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.

') + + rows + '' + ('

Recover a lost authenticator

' if self.factor_recovery else '

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.

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)