Present single-use password setup handoff
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

This commit is contained in:
tegwick 2026-07-28 17:30:17 +02:00
parent bbe291dac3
commit 4efce34821
5 changed files with 45 additions and 0 deletions

View file

@ -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
),
)

View file

@ -63,6 +63,7 @@ class ProvisioningResult:
external_subject: str
status: str
resumed: bool = False
password_setup_url: str | None = None
class IdentityProvisioningPort(Protocol):

View file

@ -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"<td>{'linked' if directory else 'pending'}</td><td>{action}</td></tr>"
)
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",
"<h1>Login identity created</h1>"
"<p>The password is handled only by the NetKingdom identity "
"surface. This short-lived link is single use.</p>"
f'<p><a class="button" rel="noreferrer" href="{escape(setup_url)}">'
"Continue to password setup</a></p>"
f'<p><a href="/admin/{escape(tenant)}">'
"Return to tenant administration</a></p>",
)
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""]

View file

@ -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)

View file

@ -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):