Add public registration browser journey
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-10 15:59:50 +02:00
parent c12bc604a8
commit 0669fa7a85
3 changed files with 231 additions and 14 deletions

View file

@ -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",
"<h1>Check your email.</h1>"
"<p>If the address can be registered, a verification link is on its way. "
"The link expires after 30 minutes.</p>",
),
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",
"<h1>Your identity is ready.</h1>"
"<p>Continue to the protected identity service to create your password.</p>"
f'<p><a class="button" rel="noreferrer" href="{escape(provisioned.password_setup_url)}">Create password</a></p>',
),
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"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
if actor is not None
else f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
else (
f'<p><a class="button" href="/login">Sign in with KeyCape</a></p>'
+ (
'<p>New here? <a href="/register">Create an account</a>.</p>'
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'<option value="{escape(item)}">{escape(item)}</option>'
for item in sorted(self.registration_clients)
)
tenant_options = "".join(
f'<option value="{escape(item)}">{escape(item.removeprefix("tenant:"))}</option>'
for item in sorted(self.registration_tenants)
)
return self._page_html(
"Create account",
f"""<h1>Create your account.</h1>
<p>We will verify your email before creating an identity.</p>
<form method="post" action="/register">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<label>Username <input name="username" required minlength="3" maxlength="32" pattern="[A-Za-z][A-Za-z0-9._-]+" autocomplete="username"></label>
<label>Email <input name="email" type="email" required autocomplete="email"></label>
<label>Display name <input name="display_name" maxlength="200" autocomplete="name"></label>
<label>Application <select name="client_id" required>{client_options}</select></label>
<label>Community <select name="tenant" required>{tenant_options}</select></label>
<button type="submit">Send verification email</button>
</form><p><a href="/login">Already have an account? Sign in</a></p>""",
)
def _registration_verification_form(self, csrf_token: str, handle: str) -> str:
return self._page_html(
"Verify email",
f"""<h1>Verify your email.</h1>
<p>Confirm to finish creating your identity. This verification link can be used once.</p>
<form method="post" action="/registration/verify">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<input type="hidden" name="handle" value="{escape(handle)}">
<button type="submit">Verify and continue</button></form>""",
)
def _registration_resume_form(
self, csrf_token: str, registration_id: str, resume_handle: str
) -> str:
return self._page_html(
"Finish account setup",
f"""<h1>Your email is verified.</h1>
<p>The identity service is temporarily unavailable. Try again without repeating verification.</p>
<form method="post" action="/registration/resume">
<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
<input type="hidden" name="registration_id" value="{escape(registration_id)}">
<input type="hidden" name="resume_handle" value="{escape(resume_handle)}">
<button type="submit">Try identity setup again</button></form>""",
)
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}}}}
</style></head><body><header><strong>Railiance identity</strong></header><main>{body}</main></body></html>"""
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]:

View file

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

View file

@ -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"
```