From 0669fa7a8564806cd9d63bd801276a1a987791e4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 10 Aug 2026 15:59:50 +0200 Subject: [PATCH] Add public registration browser journey --- src/user_engine/web.py | 172 +++++++++++++++++- tests/test_web.py | 48 ++++- ...gistration-and-jit-application-profiles.md | 25 ++- 3 files changed, 231 insertions(+), 14 deletions(-) diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 9939c3a..37a3dff 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -171,6 +171,44 @@ class PortalApplication: actor = self._optional_actor(environ) return self._html(start_response, self._home(actor), correlation_id) + if path == "/register" and method == "GET": + if not self.public_registration or self.registration_verification is None: + raise NotFoundError("public registration is unavailable") + token = secrets.token_urlsafe(32) + return self._html( + start_response, self._registration_form(token), correlation_id, + extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))], + ) + if path == "/register" and method == "POST": + body = self._form_body(environ) + self._require_registration_csrf(environ, str(body.get("csrf_token", ""))) + return self._start_public_registration( + environ, start_response, correlation_id, body=body, browser=True + ) + if path == "/registration/verify" and method == "GET": + query = parse_qs(str(environ.get("QUERY_STRING", ""))) + handle = str(query.get("handle", [""])[0]) + if len(handle) < 16: + raise ValidationError("verification handle is invalid") + token = secrets.token_urlsafe(32) + return self._html( + start_response, self._registration_verification_form(token, handle), + correlation_id, + extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))], + ) + if path == "/registration/verify" and method == "POST": + body = self._form_body(environ) + self._require_registration_csrf(environ, str(body.get("csrf_token", ""))) + return self._verify_public_registration( + environ, start_response, correlation_id, body=body, browser=True + ) + if path == "/registration/resume" and method == "POST": + body = self._form_body(environ) + self._require_registration_csrf(environ, str(body.get("csrf_token", ""))) + return self._resume_public_registration( + environ, start_response, correlation_id, body=body, browser=True + ) + if path == "/api/v1/public/registrations" and method == "POST": return self._start_public_registration( environ, start_response, correlation_id @@ -820,10 +858,11 @@ class PortalApplication: environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str, + *, body: Mapping[str, Any] | None = None, browser: bool = False, ) -> Iterable[bytes]: if not self.public_registration or self.registration_verification is None: raise NotFoundError("public registration is unavailable") - body = self._body(environ) + body = body if body is not None else self._body(environ) username = self._registration_username(body.get("username")) email = self._registration_email(body.get("email")) display_name = str(body.get("display_name") or "").strip() or None @@ -865,6 +904,17 @@ class PortalApplication: display_name=display_name, ) ) + if browser: + return self._html( + start_response, + self._page_html( + "Check your email", + "

Check your email.

" + "

If the address can be registered, a verification link is on its way. " + "The link expires after 30 minutes.

", + ), + correlation_id, + ) return self._json( start_response, "202 Accepted", @@ -877,10 +927,11 @@ class PortalApplication: environ: Mapping[str, Any], start_response: StartResponse, correlation_id: str, + *, body: Mapping[str, Any] | None = None, browser: bool = False, ) -> Iterable[bytes]: if not self.public_registration or self.registration_verification is None: raise NotFoundError("public registration is unavailable") - body = self._body(environ) + body = body if body is not None else self._body(environ) evidence = self.registration_verification.consume(str(body.get("handle") or "")) session = self.service.store.registration_session(evidence.registration_id) if session is None: @@ -932,19 +983,32 @@ class PortalApplication: return self._provision_public_registration( start_response, actor, completion.user, completion.session, evidence.normalized_email, evidence.display_name, - evidence.preferred_username, correlation_id, + evidence.preferred_username, correlation_id, browser=browser, ) except RuntimeError: + if browser: + token = secrets.token_urlsafe(32) + return self._html( + start_response, + self._registration_resume_form( + token, updated.registration_id, resume_handle + ), + correlation_id, + extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))], + ) return self._json(start_response, "202 Accepted", { "status": "provisioning_pending", "registration_id": updated.registration_id, "resume_handle": resume_handle, }, correlation_id) - def _resume_public_registration(self, environ, start_response, correlation_id): + def _resume_public_registration( + self, environ, start_response, correlation_id, *, + body: Mapping[str, Any] | None = None, browser: bool = False, + ): if not self.public_registration: raise NotFoundError("public registration is unavailable") - body = self._body(environ) + body = body if body is not None else self._body(environ) session = self.service.store.registration_session(str(body.get("registration_id") or "")) handle = str(body.get("resume_handle") or "") digest = hashlib.sha256(handle.encode()).hexdigest() @@ -970,11 +1034,13 @@ class PortalApplication: return self._provision_public_registration( start_response, actor, user, session, email_factor.normalized_value, user.display_name, str(session.applicant_username), correlation_id, + browser=browser, ) def _provision_public_registration( self, start_response, actor, user, session, email, display_name, preferred_username, correlation_id, + *, browser: bool = False, ): provisioned = self.provisioning.provision( ProvisioningRequest( @@ -1001,6 +1067,17 @@ class PortalApplication: ) if provisioned.password_setup_url: self._validate_registration_handoff(provisioned.password_setup_url) + if browser: + return self._html( + start_response, + self._page_html( + "Create your password", + "

Your identity is ready.

" + "

Continue to the protected identity service to create your password.

" + f'

Create password

', + ), + correlation_id, + ) return self._redirect( start_response, provisioned.password_setup_url, correlation_id ) @@ -1075,6 +1152,22 @@ class PortalApplication: if not supplied or not secrets.compare_digest(supplied, expected): raise AuthorizationDenied("invalid CSRF token") + @staticmethod + def _registration_csrf_cookie(token: str) -> str: + return ( + f"ue_registration_csrf={token}; Path=/; HttpOnly; Secure; " + "SameSite=Strict; Max-Age=1800" + ) + + def _require_registration_csrf( + self, environ: Mapping[str, Any], supplied: str + ) -> None: + expected = cookie_value( + str(environ.get("HTTP_COOKIE", "")), "ue_registration_csrf" + ) + if not supplied or not expected or not secrets.compare_digest(supplied, expected): + raise AuthorizationDenied("invalid registration CSRF token") + @staticmethod def _page(environ: Mapping[str, Any]) -> tuple[int, int]: query = parse_qs(str(environ.get("QUERY_STRING", ""))) @@ -1101,7 +1194,14 @@ class PortalApplication: f"

Signed in as {escape(actor.preferred_username)}.

" '

Continue onboarding

' if actor is not None - else f'

Sign in with KeyCape

' + else ( + f'

Sign in with KeyCape

' + + ( + '

New here? Create an account.

' + if self.public_registration and self.registration_verification is not None + else "" + ) + ) ) return self._page_html( "Identity & access", @@ -1110,6 +1210,54 @@ class PortalApplication: + identity, ) + def _registration_form(self, csrf_token: str) -> str: + client_options = "".join( + f'' + for item in sorted(self.registration_clients) + ) + tenant_options = "".join( + f'' + for item in sorted(self.registration_tenants) + ) + return self._page_html( + "Create account", + f"""

Create your account.

+

We will verify your email before creating an identity.

+
+ + + + + + + +

Already have an account? Sign in

""", + ) + + def _registration_verification_form(self, csrf_token: str, handle: str) -> str: + return self._page_html( + "Verify email", + f"""

Verify your email.

+

Confirm to finish creating your identity. This verification link can be used once.

+
+ + +
""", + ) + + def _registration_resume_form( + self, csrf_token: str, registration_id: str, resume_handle: str + ) -> str: + return self._page_html( + "Finish account setup", + f"""

Your email is verified.

+

The identity service is temporarily unavailable. Try again without repeating verification.

+
+ + + +
""", + ) def _admin( self, tenant: str, memberships: tuple[Any, ...], invitations: tuple[Any, ...], diagnostics: Any, @@ -1328,9 +1476,17 @@ input,select,button{{font:inherit;padding:.65rem}}button{{background:var(--accen a:focus-visible,input:focus-visible,select:focus-visible,button:focus-visible{{outline:3px solid #e59f24;outline-offset:3px}}@media(max-width:640px){{body{{font-size:16px}}table{{display:block;overflow-x:auto}}}}
Railiance identity
{body}
""" - def _html(self, start_response: StartResponse, body: str, correlation_id: str) -> list[bytes]: + def _html( + self, start_response: StartResponse, body: str, correlation_id: str, + *, extra_headers: list[tuple[str, str]] | None = None, + ) -> list[bytes]: data = body.encode() - start_response("200 OK", [("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(data))), *self._security_headers(correlation_id)]) + start_response("200 OK", [ + ("Content-Type", "text/html; charset=utf-8"), + ("Content-Length", str(len(data))), + *(extra_headers or []), + *self._security_headers(correlation_id), + ]) return [data] def _json(self, start_response: StartResponse, status: str, payload: Any, correlation_id: str) -> list[bytes]: diff --git a/tests/test_web.py b/tests/test_web.py index b12861b..679c875 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,5 +1,6 @@ import io import json +import re import unittest from dataclasses import replace from datetime import timedelta @@ -29,7 +30,7 @@ SECRET = "test-proxy-secret-with-adequate-length" def invoke( app, path, *, method="GET", claims=None, marker=SECRET, body=None, - form=None, cookie=None, headers=None, + form=None, cookie=None, headers=None, query="", ): payload = ( urlencode(form).encode() @@ -39,7 +40,7 @@ def invoke( environ = { "REQUEST_METHOD": method, "PATH_INFO": path, - "QUERY_STRING": "", + "QUERY_STRING": query, "CONTENT_LENGTH": str(len(payload)), "wsgi.input": io.BytesIO(payload), "HTTP_X_REQUEST_ID": "corr_test", @@ -239,6 +240,49 @@ class PortalApplicationTests(unittest.TestCase): provision.idempotency_key, ) + def test_public_registration_browser_journey_uses_csrf_and_confirmation(self): + verifier = FakeRegistrationVerification() + self.app.registration_verification = verifier + self.app.provisioning = FakeProvisioning() + self.app.registration_clients = frozenset({"coulomb-social"}) + self.app.registration_tenants = frozenset({"tenant:coulomb"}) + self.app.registration_oidc_issuer = "https://kc.example" + self.app.registration_password_setup_origins = frozenset({"https://kc.example"}) + + page, html = invoke(self.app, "/register") + self.assertEqual("200 OK", page["status"]) + self.assertIn(b"Create your account", html) + cookie = page["headers"]["Set-Cookie"] + token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode() + denied, _ = invoke(self.app, "/register", method="POST", form={ + "csrf_token": "wrong", "username": "new.person", + "email": "new@example.test", "client_id": "coulomb-social", + "tenant": "tenant:coulomb", + }, cookie=cookie) + self.assertEqual("403 Forbidden", denied["status"]) + started, html = invoke(self.app, "/register", method="POST", form={ + "csrf_token": token, "username": "new.person", + "email": "new@example.test", "display_name": "New Person", + "client_id": "coulomb-social", "tenant": "tenant:coulomb", + }, cookie=cookie) + self.assertEqual("200 OK", started["status"]) + self.assertIn(b"Check your email", html) + + verifier.registration_id = verifier.requested.registration_id + confirmation, html = invoke( + self.app, "/registration/verify", query="handle=" + "x" * 32 + ) + self.assertEqual("200 OK", confirmation["status"]) + self.assertIn(b"Verify and continue", html) + cookie = confirmation["headers"]["Set-Cookie"] + token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode() + completed, html = invoke( + self.app, "/registration/verify", method="POST", cookie=cookie, + form={"csrf_token": token, "handle": "x" * 32}, + ) + self.assertEqual("200 OK", completed["status"]) + self.assertIn(b"Create password", html) + def test_public_registration_rejects_untrusted_password_setup_origin(self): verifier = FakeRegistrationVerification() self.app.registration_verification = verifier diff --git a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md index e7739d0..65256e2 100644 --- a/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md +++ b/workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: netkingdom created: "2026-08-09" -updated: "2026-08-09" +updated: "2026-08-10" depends_on: - USER-WP-0021 - NK-WP-0025 @@ -26,7 +26,7 @@ identity creation to NetKingdom. ```task id: USER-WP-0022-T01 -status: wait +status: progress priority: high state_hub_task_id: "43dd49b7-6dbc-4117-a401-6d9f6e59aa26" ``` @@ -65,11 +65,21 @@ KeyCape issuer, and redirect only to an allow-listed HTTPS provider password setup origin. Full suite now passes 126 tests with 3 skips. Automated recovery after local completion/provider failure remains open before production enablement. +2026-08-10 browser increment: `/register` now provides the accessible public +account form and the home page links to it only when registration is configured. +Anonymous mutations use a short-lived Secure/HttpOnly/SameSite double-submit +CSRF cookie. Verification links land on an explicit one-time confirmation page, +successful provisioning presents the allow-listed provider password handoff, +and provider outages render a retry form backed by the separate resume handle. +Responses remain non-enumerating. The full suite passes 128 tests with three +environment-dependent skips. Cancellation, ingress rate limiting, and the +credential-gated production enablement remain. + ## T02 - Orchestrate provider identity creation ```task id: USER-WP-0022-T02 -status: wait +status: done priority: high state_hub_task_id: "74239ce4-5c1e-46cf-8abf-3cbec3f3f989" ``` @@ -97,6 +107,13 @@ digest, retries with the same registration idempotency key and local user, and invalidates the handle after provider linking. Replay is denied. The full suite passes 127 tests with 3 environment-dependent skips. +2026-08-10 completion: identity-provisioner now validates and honors the +canonical preferred username instead of silently deriving it from the mailbox. +The corrected image is live at immutable digest +`sha256:4a6ec93d74eed6e17065a7e5d4c3d8d0ed14078bdfda0112738438528c6b2680`. +Together with the deterministic retry path, this completes the provider +orchestration boundary without exposing provider credentials or passwords. + ## T03 - Create application profiles on first login ```task @@ -143,7 +160,7 @@ step-up. user-engine must not become the token assurance authority. ```task id: USER-WP-0022-T05 -status: todo +status: progress priority: high state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6" ```