From 4efce3482158cba47f5bd41f3911de084865baed Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 28 Jul 2026 17:30:17 +0200 Subject: [PATCH] Present single-use password setup handoff --- src/user_engine/adapters/provisioning.py | 5 +++++ src/user_engine/ports.py | 1 + src/user_engine/web.py | 22 ++++++++++++++++++++++ tests/test_provisioning_adapter.py | 5 +++++ tests/test_web.py | 12 ++++++++++++ 5 files changed, 45 insertions(+) diff --git a/src/user_engine/adapters/provisioning.py b/src/user_engine/adapters/provisioning.py index ecbdec7..c5bacfe 100644 --- a/src/user_engine/adapters/provisioning.py +++ b/src/user_engine/adapters/provisioning.py @@ -66,4 +66,9 @@ class HTTPIdentityProvisioningAdapter: external_subject=str(result["external_subject"]), status=str(result["status"]), resumed=bool(result.get("resumed", False)), + password_setup_url=( + str(result["password_setup_url"]) + if result.get("password_setup_url") + else None + ), ) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 2861f4d..2493033 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -63,6 +63,7 @@ class ProvisioningResult: external_subject: str status: str resumed: bool = False + password_setup_url: str | None = None class IdentityProvisioningPort(Protocol): diff --git a/src/user_engine/web.py b/src/user_engine/web.py index a5101f6..965ca6e 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -302,6 +302,14 @@ class PortalApplication: subject=result.external_subject, provider=result.provider, correlation_id=correlation_id, ) + if result.password_setup_url: + return self._html( + start_response, + self._password_setup_handoff( + result.password_setup_url, tenant + ), + correlation_id, + ) query = urlencode({"provisioned": user.user_id, "status": result.status}) return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id) if len(parts) == 6 and parts[3] == "users" and parts[5] == "status": @@ -481,6 +489,20 @@ class PortalApplication: f"{'linked' if directory else 'pending'}{action}" ) + def _password_setup_handoff(self, setup_url: str, tenant: str) -> str: + if not setup_url.startswith("https://"): + raise ValidationError("password setup handoff must use HTTPS") + return self._page_html( + "Password setup", + "

Login identity created

" + "

The password is handled only by the NetKingdom identity " + "surface. This short-lived link is single use.

" + f'

' + "Continue to password setup

" + f'

' + "Return to tenant administration

", + ) + def _redirect(self, start_response: StartResponse, location: str, correlation_id: str) -> list[bytes]: start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)]) return [b""] diff --git a/tests/test_provisioning_adapter.py b/tests/test_provisioning_adapter.py index 84ec4e1..5f4a162 100644 --- a/tests/test_provisioning_adapter.py +++ b/tests/test_provisioning_adapter.py @@ -20,6 +20,7 @@ class ProvisioningAdapterTests(unittest.TestCase): "external_subject": "person-1", "status": "password_setup_required", "resumed": True, + "password_setup_url": "https://kc.example/setup/password?token=opaque", }).encode()) adapter = HTTPIdentityProvisioningAdapter( base_url="http://provisioner", bearer_token="secret\n" @@ -33,6 +34,10 @@ class ProvisioningAdapterTests(unittest.TestCase): correlation_id="corr-1", )) self.assertTrue(result.resumed) + self.assertEqual( + "https://kc.example/setup/password?token=opaque", + result.password_setup_url, + ) request = opener.call_args.args[0] self.assertEqual("Bearer secret", request.headers["Authorization"]) self.assertIn(b"idem-1234567890123456", request.data) diff --git a/tests/test_web.py b/tests/test_web.py index 5756d5f..5acaf4d 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -187,6 +187,17 @@ class PortalApplicationTests(unittest.TestCase): self.assertEqual("200 OK", page["status"]) self.assertIn(b"ada@example.test", html) self.assertIn(b"Create login", html) + user_id = next(iter(self.app.service.store.users)) + handoff, html = invoke( + self.app, + f"/admin/tenant:friendly:binky/users/{user_id}/provision", + method="POST", + cookie="ue_session=browser", + form={"csrf_token": "csrf-test-token"}, + ) + self.assertEqual("200 OK", handoff["status"]) + self.assertIn(b"Continue to password setup", html) + self.assertIn(b"https://kc.example/setup/password?token=opaque", html) class FakeProvisioning: @@ -199,6 +210,7 @@ class FakeProvisioning: provider="netkingdom-lldap", external_subject=request.primary_email.split("@")[0], status="password_setup_required", + password_setup_url="https://kc.example/setup/password?token=opaque", ) def suspend(self, *, external_subject, idempotency_key, correlation_id):