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) actor = self._optional_actor(environ)
return self._html(start_response, self._home(actor), correlation_id) 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": if path == "/api/v1/public/registrations" and method == "POST":
return self._start_public_registration( return self._start_public_registration(
environ, start_response, correlation_id environ, start_response, correlation_id
@ -820,10 +858,11 @@ class PortalApplication:
environ: Mapping[str, Any], environ: Mapping[str, Any],
start_response: StartResponse, start_response: StartResponse,
correlation_id: str, correlation_id: str,
*, body: Mapping[str, Any] | None = None, browser: bool = False,
) -> Iterable[bytes]: ) -> Iterable[bytes]:
if not self.public_registration or self.registration_verification is None: if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable") 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")) username = self._registration_username(body.get("username"))
email = self._registration_email(body.get("email")) email = self._registration_email(body.get("email"))
display_name = str(body.get("display_name") or "").strip() or None display_name = str(body.get("display_name") or "").strip() or None
@ -865,6 +904,17 @@ class PortalApplication:
display_name=display_name, 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( return self._json(
start_response, start_response,
"202 Accepted", "202 Accepted",
@ -877,10 +927,11 @@ class PortalApplication:
environ: Mapping[str, Any], environ: Mapping[str, Any],
start_response: StartResponse, start_response: StartResponse,
correlation_id: str, correlation_id: str,
*, body: Mapping[str, Any] | None = None, browser: bool = False,
) -> Iterable[bytes]: ) -> Iterable[bytes]:
if not self.public_registration or self.registration_verification is None: if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable") 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 "")) evidence = self.registration_verification.consume(str(body.get("handle") or ""))
session = self.service.store.registration_session(evidence.registration_id) session = self.service.store.registration_session(evidence.registration_id)
if session is None: if session is None:
@ -932,19 +983,32 @@ class PortalApplication:
return self._provision_public_registration( return self._provision_public_registration(
start_response, actor, completion.user, completion.session, start_response, actor, completion.user, completion.session,
evidence.normalized_email, evidence.display_name, evidence.normalized_email, evidence.display_name,
evidence.preferred_username, correlation_id, evidence.preferred_username, correlation_id, browser=browser,
) )
except RuntimeError: 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", { return self._json(start_response, "202 Accepted", {
"status": "provisioning_pending", "status": "provisioning_pending",
"registration_id": updated.registration_id, "registration_id": updated.registration_id,
"resume_handle": resume_handle, "resume_handle": resume_handle,
}, correlation_id) }, 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: if not self.public_registration:
raise NotFoundError("public registration is unavailable") 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 "")) session = self.service.store.registration_session(str(body.get("registration_id") or ""))
handle = str(body.get("resume_handle") or "") handle = str(body.get("resume_handle") or "")
digest = hashlib.sha256(handle.encode()).hexdigest() digest = hashlib.sha256(handle.encode()).hexdigest()
@ -970,11 +1034,13 @@ class PortalApplication:
return self._provision_public_registration( return self._provision_public_registration(
start_response, actor, user, session, email_factor.normalized_value, start_response, actor, user, session, email_factor.normalized_value,
user.display_name, str(session.applicant_username), correlation_id, user.display_name, str(session.applicant_username), correlation_id,
browser=browser,
) )
def _provision_public_registration( def _provision_public_registration(
self, start_response, actor, user, session, email, display_name, self, start_response, actor, user, session, email, display_name,
preferred_username, correlation_id, preferred_username, correlation_id,
*, browser: bool = False,
): ):
provisioned = self.provisioning.provision( provisioned = self.provisioning.provision(
ProvisioningRequest( ProvisioningRequest(
@ -1001,6 +1067,17 @@ class PortalApplication:
) )
if provisioned.password_setup_url: if provisioned.password_setup_url:
self._validate_registration_handoff(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( return self._redirect(
start_response, provisioned.password_setup_url, correlation_id start_response, provisioned.password_setup_url, correlation_id
) )
@ -1075,6 +1152,22 @@ class PortalApplication:
if not supplied or not secrets.compare_digest(supplied, expected): if not supplied or not secrets.compare_digest(supplied, expected):
raise AuthorizationDenied("invalid CSRF token") 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 @staticmethod
def _page(environ: Mapping[str, Any]) -> tuple[int, int]: def _page(environ: Mapping[str, Any]) -> tuple[int, int]:
query = parse_qs(str(environ.get("QUERY_STRING", ""))) 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>" f"<p>Signed in as <strong>{escape(actor.preferred_username)}</strong>.</p>"
'<p><a class="button" href="/onboarding">Continue onboarding</a></p>' '<p><a class="button" href="/onboarding">Continue onboarding</a></p>'
if actor is not None 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( return self._page_html(
"Identity & access", "Identity & access",
@ -1110,6 +1210,54 @@ class PortalApplication:
+ identity, + 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( def _admin(
self, tenant: str, memberships: tuple[Any, ...], self, tenant: str, memberships: tuple[Any, ...],
invitations: tuple[Any, ...], diagnostics: 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}}}} 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>""" </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() 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] return [data]
def _json(self, start_response: StartResponse, status: str, payload: Any, correlation_id: str) -> list[bytes]: def _json(self, start_response: StartResponse, status: str, payload: Any, correlation_id: str) -> list[bytes]:

View file

@ -1,5 +1,6 @@
import io import io
import json import json
import re
import unittest import unittest
from dataclasses import replace from dataclasses import replace
from datetime import timedelta from datetime import timedelta
@ -29,7 +30,7 @@ SECRET = "test-proxy-secret-with-adequate-length"
def invoke( def invoke(
app, path, *, method="GET", claims=None, marker=SECRET, body=None, app, path, *, method="GET", claims=None, marker=SECRET, body=None,
form=None, cookie=None, headers=None, form=None, cookie=None, headers=None, query="",
): ):
payload = ( payload = (
urlencode(form).encode() urlencode(form).encode()
@ -39,7 +40,7 @@ def invoke(
environ = { environ = {
"REQUEST_METHOD": method, "REQUEST_METHOD": method,
"PATH_INFO": path, "PATH_INFO": path,
"QUERY_STRING": "", "QUERY_STRING": query,
"CONTENT_LENGTH": str(len(payload)), "CONTENT_LENGTH": str(len(payload)),
"wsgi.input": io.BytesIO(payload), "wsgi.input": io.BytesIO(payload),
"HTTP_X_REQUEST_ID": "corr_test", "HTTP_X_REQUEST_ID": "corr_test",
@ -239,6 +240,49 @@ class PortalApplicationTests(unittest.TestCase):
provision.idempotency_key, 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): def test_public_registration_rejects_untrusted_password_setup_origin(self):
verifier = FakeRegistrationVerification() verifier = FakeRegistrationVerification()
self.app.registration_verification = verifier self.app.registration_verification = verifier

View file

@ -8,7 +8,7 @@ status: active
owner: codex owner: codex
topic_slug: netkingdom topic_slug: netkingdom
created: "2026-08-09" created: "2026-08-09"
updated: "2026-08-09" updated: "2026-08-10"
depends_on: depends_on:
- USER-WP-0021 - USER-WP-0021
- NK-WP-0025 - NK-WP-0025
@ -26,7 +26,7 @@ identity creation to NetKingdom.
```task ```task
id: USER-WP-0022-T01 id: USER-WP-0022-T01
status: wait status: progress
priority: high priority: high
state_hub_task_id: "43dd49b7-6dbc-4117-a401-6d9f6e59aa26" 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 setup origin. Full suite now passes 126 tests with 3 skips. Automated recovery
after local completion/provider failure remains open before production enablement. 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 ## T02 - Orchestrate provider identity creation
```task ```task
id: USER-WP-0022-T02 id: USER-WP-0022-T02
status: wait status: done
priority: high priority: high
state_hub_task_id: "74239ce4-5c1e-46cf-8abf-3cbec3f3f989" 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 and invalidates the handle after provider linking. Replay is denied. The full
suite passes 127 tests with 3 environment-dependent skips. 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 ## T03 - Create application profiles on first login
```task ```task
@ -143,7 +160,7 @@ step-up. user-engine must not become the token assurance authority.
```task ```task
id: USER-WP-0022-T05 id: USER-WP-0022-T05
status: todo status: progress
priority: high priority: high
state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6" state_hub_task_id: "0a5a5f3a-0d47-4d5d-bda5-e2c7c737fee6"
``` ```