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"]), external_subject=str(result["external_subject"]),
status=str(result["status"]), status=str(result["status"]),
resumed=bool(result.get("resumed", False)), 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 external_subject: str
status: str status: str
resumed: bool = False resumed: bool = False
password_setup_url: str | None = None
class IdentityProvisioningPort(Protocol): class IdentityProvisioningPort(Protocol):

View file

@ -302,6 +302,14 @@ class PortalApplication:
subject=result.external_subject, provider=result.provider, subject=result.external_subject, provider=result.provider,
correlation_id=correlation_id, 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}) query = urlencode({"provisioned": user.user_id, "status": result.status})
return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id) return self._redirect(start_response, f"/admin/{tenant}?{query}", correlation_id)
if len(parts) == 6 and parts[3] == "users" and parts[5] == "status": 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>" 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]: 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)]) start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
return [b""] return [b""]

View file

@ -20,6 +20,7 @@ class ProvisioningAdapterTests(unittest.TestCase):
"external_subject": "person-1", "external_subject": "person-1",
"status": "password_setup_required", "status": "password_setup_required",
"resumed": True, "resumed": True,
"password_setup_url": "https://kc.example/setup/password?token=opaque",
}).encode()) }).encode())
adapter = HTTPIdentityProvisioningAdapter( adapter = HTTPIdentityProvisioningAdapter(
base_url="http://provisioner", bearer_token="secret\n" base_url="http://provisioner", bearer_token="secret\n"
@ -33,6 +34,10 @@ class ProvisioningAdapterTests(unittest.TestCase):
correlation_id="corr-1", correlation_id="corr-1",
)) ))
self.assertTrue(result.resumed) self.assertTrue(result.resumed)
self.assertEqual(
"https://kc.example/setup/password?token=opaque",
result.password_setup_url,
)
request = opener.call_args.args[0] request = opener.call_args.args[0]
self.assertEqual("Bearer secret", request.headers["Authorization"]) self.assertEqual("Bearer secret", request.headers["Authorization"])
self.assertIn(b"idem-1234567890123456", request.data) self.assertIn(b"idem-1234567890123456", request.data)

View file

@ -187,6 +187,17 @@ class PortalApplicationTests(unittest.TestCase):
self.assertEqual("200 OK", page["status"]) self.assertEqual("200 OK", page["status"])
self.assertIn(b"ada@example.test", html) self.assertIn(b"ada@example.test", html)
self.assertIn(b"Create login", 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: class FakeProvisioning:
@ -199,6 +210,7 @@ class FakeProvisioning:
provider="netkingdom-lldap", provider="netkingdom-lldap",
external_subject=request.primary_email.split("@")[0], external_subject=request.primary_email.split("@")[0],
status="password_setup_required", status="password_setup_required",
password_setup_url="https://kc.example/setup/password?token=opaque",
) )
def suspend(self, *, external_subject, idempotency_key, correlation_id): def suspend(self, *, external_subject, idempotency_key, correlation_id):