From c36a09bded772ed6a7b33d10c4599f48e6d9e8ff Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 10 Aug 2026 11:26:18 +0200 Subject: [PATCH] Implement verified public registration flow --- WORK-RECORDS.md | 6 + src/user_engine/adapters/__init__.py | 4 + src/user_engine/adapters/provisioning.py | 2 + .../adapters/registration_verification.py | 86 ++++++++ src/user_engine/domain/models.py | 2 + src/user_engine/ports.py | 49 +++++ src/user_engine/runtime.py | 28 +++ src/user_engine/service.py | 4 + src/user_engine/web.py | 206 +++++++++++++++++- .../test_registration_verification_adapter.py | 68 ++++++ tests/test_web.py | 146 ++++++++++++- ...gistration-and-jit-application-profiles.md | 16 ++ 12 files changed, 610 insertions(+), 7 deletions(-) create mode 100644 src/user_engine/adapters/registration_verification.py create mode 100644 tests/test_registration_verification_adapter.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index bc8160d..898a02b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -29,6 +29,7 @@ | workplan | USER-WP-0019 | finished | — | workplans/USER-WP-0019-provider-backed-postgres-conformance.md | | workplan | USER-WP-0020 | finished | — | workplans/USER-WP-0020-self-service-and-user-administration-portal.md | | workplan | USER-WP-0021 | active | — | workplans/USER-WP-0021-portal-product-expansion.md | +| workplan | USER-WP-0022 | active | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | | task | USER-WP-0001-T1 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | | task | USER-WP-0001-T2 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | | task | USER-WP-0001-T3 | done | — | workplans/USER-WP-0001-preparation-and-interface-adoption.md | @@ -159,3 +160,8 @@ | task | USER-WP-0021-T03 | done | — | workplans/USER-WP-0021-portal-product-expansion.md | | task | USER-WP-0021-T04 | done | — | workplans/USER-WP-0021-portal-product-expansion.md | | task | USER-WP-0021-T05 | wait | — | workplans/USER-WP-0021-portal-product-expansion.md | +| task | USER-WP-0022-T01 | wait | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | +| task | USER-WP-0022-T02 | wait | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | +| task | USER-WP-0022-T03 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | +| task | USER-WP-0022-T04 | cancel | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | +| task | USER-WP-0022-T05 | todo | — | workplans/USER-WP-0022-public-registration-and-jit-application-profiles.md | diff --git a/src/user_engine/adapters/__init__.py b/src/user_engine/adapters/__init__.py index e2ed8ca..75a77b3 100644 --- a/src/user_engine/adapters/__init__.py +++ b/src/user_engine/adapters/__init__.py @@ -10,6 +10,9 @@ from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter +from user_engine.adapters.registration_verification import ( + HTTPRegistrationVerificationAdapter, +) __all__ = [ "InMemoryUserEngineStore", @@ -20,4 +23,5 @@ __all__ = [ "HTTPTenantManagementAdapter", "FlexAuthHTTPAdapter", "HTTPOutboxDeliveryAdapter", + "HTTPRegistrationVerificationAdapter", ] diff --git a/src/user_engine/adapters/provisioning.py b/src/user_engine/adapters/provisioning.py index f85d878..237ae41 100644 --- a/src/user_engine/adapters/provisioning.py +++ b/src/user_engine/adapters/provisioning.py @@ -36,6 +36,7 @@ class HTTPIdentityProvisioningAdapter: "idempotency_key": request.idempotency_key, "correlation_id": request.correlation_id, "roles": request.roles, + "preferred_username": request.preferred_username, }) def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult: @@ -88,6 +89,7 @@ class HTTPIdentityProvisioningAdapter: "idempotency_key": request.idempotency_key, "correlation_id": request.correlation_id, "roles": request.roles, + "preferred_username": request.preferred_username, "desired_status": desired_status, }) return IdentityDriftResult( diff --git a/src/user_engine/adapters/registration_verification.py b/src/user_engine/adapters/registration_verification.py new file mode 100644 index 0000000..09e429c --- /dev/null +++ b/src/user_engine/adapters/registration_verification.py @@ -0,0 +1,86 @@ +"""HTTP adapter for the NetKingdom mailbox-verification issuer.""" + +from __future__ import annotations + +import json +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from user_engine.ports import ( + RegistrationVerificationReceipt, + RegistrationVerificationRequest, + VerifiedRegistrationApplicant, +) + + +class HTTPRegistrationVerificationAdapter: + def __init__( + self, *, base_url: str, bearer_token: str, timeout: float = 5.0 + ) -> None: + self.base_url = base_url.rstrip("/") + self.bearer_token = bearer_token.strip() + if not self.bearer_token: + raise ValueError("bearer token must not be empty") + self.timeout = timeout + + def request( + self, request: RegistrationVerificationRequest + ) -> RegistrationVerificationReceipt: + result = self._post( + "/v1/registration-verifications", + { + "registration_id": request.registration_id, + "email": request.normalized_email, + "preferred_username": request.preferred_username, + "client_id": request.client_id, + "tenant": request.tenant, + "correlation_id": request.correlation_id, + "display_name": request.display_name, + }, + ) + return RegistrationVerificationReceipt( + request_id=str(result["request_id"]), + accepted=bool(result.get("accepted", True)), + ) + + def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant: + if len(opaque_handle) < 32: + raise ValueError("verification handle is invalid") + result = self._post( + "/v1/registration-verifications/consume", {"handle": opaque_handle} + ) + if result.get("purpose") != "public-registration": + raise RuntimeError("verification evidence has the wrong purpose") + return VerifiedRegistrationApplicant( + verification_id=str(result["verification_id"]), + registration_id=str(result["registration_id"]), + normalized_email=str(result["email"]).strip().lower(), + preferred_username=str(result["preferred_username"]), + client_id=str(result["client_id"]), + tenant=str(result["tenant"]), + source_system=str(result["source_system"]), + assurance=dict(result.get("assurance", {})), + display_name=( + str(result["display_name"]) if result.get("display_name") else None + ), + ) + + def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + request = Request( + self.base_url + path, + data=json.dumps(payload).encode(), + headers={ + "Authorization": f"Bearer {self.bearer_token}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=self.timeout) as response: + return dict(json.loads(response.read())) + except HTTPError as exc: + # Do not reflect provider details that could reveal account state. + raise RuntimeError("registration verification was rejected") from exc + except (URLError, json.JSONDecodeError, KeyError) as exc: + raise RuntimeError("registration verification is unavailable") from exc diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index 2ea3a9f..c511006 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -577,6 +577,8 @@ class RegistrationSession: user_id: str | None = None netkingdom_id: str | None = None started_by_subject: str | None = None + applicant_username: str | None = None + client_id: str | None = None correlation_id: str | None = None created_at: datetime = field(default_factory=utc_now) updated_at: datetime = field(default_factory=utc_now) diff --git a/src/user_engine/ports.py b/src/user_engine/ports.py index 8403c22..b2a387a 100644 --- a/src/user_engine/ports.py +++ b/src/user_engine/ports.py @@ -55,6 +55,43 @@ class ProvisioningRequest: idempotency_key: str correlation_id: str roles: tuple[str, ...] = () + preferred_username: str | None = None + + +@dataclass(frozen=True) +class RegistrationVerificationRequest: + """Non-secret request for an external mailbox-control challenge.""" + + registration_id: str + normalized_email: str + preferred_username: str + client_id: str + tenant: str + correlation_id: str + display_name: str | None = None + + +@dataclass(frozen=True) +class RegistrationVerificationReceipt: + """Opaque receipt safe to use for correlation, not authentication.""" + + request_id: str + accepted: bool = True + + +@dataclass(frozen=True) +class VerifiedRegistrationApplicant: + """Purpose-bound evidence returned after consuming a single-use handle.""" + + verification_id: str + registration_id: str + normalized_email: str + preferred_username: str + client_id: str + tenant: str + source_system: str + assurance: Mapping[str, Any] + display_name: str | None = None @dataclass(frozen=True) @@ -133,6 +170,18 @@ class IdentityProvisioningPort(Protocol): """Converge managed provider state toward the requested lifecycle.""" +class RegistrationVerificationPort(Protocol): + """Mailbox verification issuer; token plaintext never enters domain state.""" + + def request( + self, request: RegistrationVerificationRequest + ) -> RegistrationVerificationReceipt: + """Request an anti-enumerating, purpose-bound verification message.""" + + def consume(self, opaque_handle: str) -> VerifiedRegistrationApplicant: + """Atomically consume verified, unexpired applicant evidence.""" + + class UserEngineStore(Protocol): """Durable persistence boundary for user-engine service behavior. diff --git a/src/user_engine/runtime.py b/src/user_engine/runtime.py index acade1a..6069d48 100644 --- a/src/user_engine/runtime.py +++ b/src/user_engine/runtime.py @@ -8,6 +8,7 @@ from wsgiref.simple_server import make_server from user_engine.adapters import ( FlexAuthHTTPAdapter, HTTPOutboxDeliveryAdapter, + HTTPRegistrationVerificationAdapter, PostgresUserEngineStore, VerifiedIdentityClaimsAdapter, HTTPIdentityProvisioningAdapter, @@ -74,6 +75,33 @@ def create_application() -> PortalApplication: ), tenant_management=tenant_management, outbox_delivery=outbox_delivery, + registration_verification=( + HTTPRegistrationVerificationAdapter( + base_url=_required("USER_ENGINE_REGISTRATION_VERIFICATION_URL"), + bearer_token=_required("USER_ENGINE_REGISTRATION_VERIFICATION_TOKEN"), + ) + if os.environ.get("USER_ENGINE_PUBLIC_REGISTRATION", "false").lower() + == "true" + else None + ), + registration_clients=tuple( + item.strip() + for item in os.environ.get("USER_ENGINE_REGISTRATION_CLIENTS", "").split(",") + if item.strip() + ), + registration_tenants=tuple( + item.strip() + for item in os.environ.get("USER_ENGINE_REGISTRATION_TENANTS", "").split(",") + if item.strip() + ), + registration_oidc_issuer=_required("USER_ENGINE_OIDC_ISSUER"), + registration_password_setup_origins=tuple( + item.strip().rstrip("/") + for item in os.environ.get( + "USER_ENGINE_REGISTRATION_PASSWORD_SETUP_ORIGINS", "" + ).split(",") + if item.strip() + ), ) diff --git a/src/user_engine/service.py b/src/user_engine/service.py index ae7ff82..f2993e9 100644 --- a/src/user_engine/service.py +++ b/src/user_engine/service.py @@ -384,6 +384,8 @@ class UserEngineService: IdentityFactorType.EMAIL, ), correlation_id: str | None = None, + applicant_username: str | None = None, + client_id: str | None = None, ) -> RegistrationSession: tenant_context = self.resolve_tenant_context(actor, tenant) correlation_id = correlation_id or new_id("corr") @@ -406,6 +408,8 @@ class UserEngineService: ), required_factor_types=required, started_by_subject=actor.subject, + applicant_username=applicant_username, + client_id=client_id, correlation_id=correlation_id, ) with self.store.transaction(): diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 6e60883..26b1cc1 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -13,14 +13,28 @@ from dataclasses import asdict, is_dataclass from enum import Enum from html import escape import json +import re import secrets from typing import Any, Callable, Iterable, Mapping -from urllib.parse import parse_qs, urlencode +from urllib.parse import parse_qs, urlencode, urlsplit -from user_engine.domain import AccountStatus, FamilyMemberSpec +from user_engine.domain import ( + AccountStatus, + Actor, + FactorVerification, + FamilyMemberSpec, + IdentityFactorType, + PrincipalType, +) from user_engine.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError from user_engine.oidc import OIDCClient, cookie_value -from user_engine.ports import IdentityProvisioningPort, ProvisioningRequest, TenantManagementPort +from user_engine.ports import ( + IdentityProvisioningPort, + ProvisioningRequest, + RegistrationVerificationPort, + RegistrationVerificationRequest, + TenantManagementPort, +) from user_engine.service import PLATFORM_TENANT, UserEngineService StartResponse = Callable[[str, list[tuple[str, str]]], Any] @@ -54,6 +68,11 @@ class PortalApplication: provisioning: IdentityProvisioningPort | None = None, tenant_management: TenantManagementPort | None = None, outbox_delivery: Callable[[Any], None] | None = None, + registration_verification: RegistrationVerificationPort | None = None, + registration_clients: tuple[str, ...] = (), + registration_tenants: tuple[str, ...] = (), + registration_oidc_issuer: str = "", + registration_password_setup_origins: tuple[str, ...] = (), ) -> None: if len(trusted_proxy_secret) < 24: raise ValueError("trusted proxy secret must contain at least 24 characters") @@ -65,6 +84,13 @@ class PortalApplication: self.provisioning = provisioning self.tenant_management = tenant_management self.outbox_delivery = outbox_delivery + self.registration_verification = registration_verification + self.registration_clients = frozenset(registration_clients) + self.registration_tenants = frozenset(registration_tenants) + self.registration_oidc_issuer = registration_oidc_issuer.rstrip("/") + self.registration_password_setup_origins = frozenset( + origin.rstrip("/") for origin in registration_password_setup_origins + ) def __call__(self, environ: Mapping[str, Any], start_response: StartResponse) -> Iterable[bytes]: correlation_id = environ.get("HTTP_X_REQUEST_ID") or f"corr_{secrets.token_hex(12)}" @@ -143,6 +169,15 @@ class PortalApplication: actor = self._optional_actor(environ) return self._html(start_response, self._home(actor), correlation_id) + if path == "/api/v1/public/registrations" and method == "POST": + return self._start_public_registration( + environ, start_response, correlation_id + ) + if path == "/api/v1/public/registrations/verify" and method == "POST": + return self._verify_public_registration( + environ, start_response, correlation_id + ) + actor = self._actor(environ) if path == "/api/v1/me" and method == "GET": return self._json(start_response, "200 OK", _jsonable(self.service.me(self._claims(environ), correlation_id=correlation_id)), correlation_id) @@ -774,6 +809,171 @@ class PortalApplication: def _actor(self, environ: Mapping[str, Any]) -> Any: return self.service.identity_adapter.normalize(self._claims(environ)) + def _start_public_registration( + self, + environ: Mapping[str, Any], + start_response: StartResponse, + correlation_id: str, + ) -> Iterable[bytes]: + if not self.public_registration or self.registration_verification is None: + raise NotFoundError("public registration is unavailable") + body = 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 + if display_name is not None and len(display_name) > 200: + raise ValidationError("display_name is too long") + client_id = str(body.get("client_id") or "") + tenant = str(body.get("tenant") or "") + if client_id not in self.registration_clients: + raise ValidationError("client_id is not eligible for registration") + if tenant not in self.registration_tenants: + raise ValidationError("tenant is not eligible for registration") + + applicant_subject = f"applicant_{secrets.token_hex(16)}" + actor = Actor( + issuer="urn:netkingdom:public-registration", + subject=applicant_subject, + tenant=tenant, + principal_type=PrincipalType.HUMAN, + audience=("user-engine",), + roles=("registration-applicant",), + authorized_party=client_id, + preferred_username=username, + ) + session = self.service.start_registration( + actor, + tenant=tenant, + correlation_id=correlation_id, + applicant_username=username, + client_id=client_id, + ) + self.registration_verification.request( + RegistrationVerificationRequest( + registration_id=session.registration_id, + normalized_email=email, + preferred_username=username, + client_id=client_id, + tenant=tenant, + correlation_id=correlation_id, + display_name=display_name, + ) + ) + return self._json( + start_response, + "202 Accepted", + {"status": "verification_requested"}, + correlation_id, + ) + + def _verify_public_registration( + self, + environ: Mapping[str, Any], + start_response: StartResponse, + correlation_id: str, + ) -> Iterable[bytes]: + if not self.public_registration or self.registration_verification is None: + raise NotFoundError("public registration is unavailable") + body = 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: + raise NotFoundError("registration not found") + if ( + evidence.client_id != session.client_id + or evidence.tenant != session.tenant + or evidence.preferred_username != session.applicant_username + ): + raise AuthorizationDenied("verification evidence does not match registration") + actor = Actor( + issuer="urn:netkingdom:public-registration", + subject=str(session.started_by_subject), + tenant=session.tenant, + principal_type=PrincipalType.HUMAN, + audience=("user-engine",), + roles=("registration-applicant",), + authorized_party=session.client_id, + preferred_username=session.applicant_username, + ) + updated = self.service.attach_registration_factor( + actor, + session.registration_id, + FactorVerification( + factor_type=IdentityFactorType.EMAIL, + normalized_value=evidence.normalized_email, + verification_id=evidence.verification_id, + source_system=evidence.source_system, + assurance=dict(evidence.assurance), + ), + correlation_id=correlation_id, + ) + if self.provisioning is None or not self.registration_oidc_issuer: + raise RuntimeError("public registration provisioning is unavailable") + completion = self.service.complete_registration( + actor, + updated.registration_id, + display_name=evidence.display_name, + primary_email=evidence.normalized_email, + correlation_id=correlation_id, + ) + provisioned = self.provisioning.provision( + ProvisioningRequest( + user_id=completion.user.user_id, + tenant=updated.tenant, + primary_email=evidence.normalized_email, + display_name=evidence.display_name, + preferred_username=evidence.preferred_username, + idempotency_key=f"public-registration-{updated.registration_id}", + correlation_id=correlation_id, + roles=("user",), + ) + ) + self.service.link_identity( + actor, + completion.user.user_id, + issuer=self.registration_oidc_issuer, + subject=provisioned.external_subject, + provider=provisioned.provider, + correlation_id=correlation_id, + ) + if provisioned.password_setup_url: + self._validate_registration_handoff(provisioned.password_setup_url) + return self._redirect( + start_response, provisioned.password_setup_url, correlation_id + ) + return self._json( + start_response, + "200 OK", + {"status": "identity_ready", "login_required": True}, + correlation_id, + ) + + def _validate_registration_handoff(self, setup_url: str) -> None: + parsed = urlsplit(setup_url) + origin = f"{parsed.scheme}://{parsed.netloc}" + if ( + parsed.scheme != "https" + or not parsed.netloc + or origin not in self.registration_password_setup_origins + ): + raise ValidationError("password setup handoff is not allow-listed") + + @staticmethod + def _registration_username(value: Any) -> str: + username = str(value or "").strip().lower() + if not re.fullmatch(r"[a-z][a-z0-9._-]{2,31}", username): + raise ValidationError("username is invalid") + if username in {"admin", "administrator", "platform-root", "root", "system"}: + raise ValidationError("username is reserved") + return username + + @staticmethod + def _registration_email(value: Any) -> str: + email = str(value or "").strip().lower() + if len(email) > 254 or not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", email): + raise ValidationError("email is invalid") + return email + def _optional_actor(self, environ: Mapping[str, Any]) -> Any | None: try: return self._actor(environ) diff --git a/tests/test_registration_verification_adapter.py b/tests/test_registration_verification_adapter.py new file mode 100644 index 0000000..6d9f990 --- /dev/null +++ b/tests/test_registration_verification_adapter.py @@ -0,0 +1,68 @@ +import io +import json +import unittest +from unittest.mock import patch + +from user_engine.adapters.registration_verification import ( + HTTPRegistrationVerificationAdapter, +) +from user_engine.ports import RegistrationVerificationRequest + + +class Response(io.BytesIO): + def __enter__(self): return self + def __exit__(self, *args): return None + + +class RegistrationVerificationAdapterTests(unittest.TestCase): + @patch("user_engine.adapters.registration_verification.urlopen") + def test_request_sends_bound_context_and_returns_only_opaque_receipt(self, opener): + opener.return_value = Response( + json.dumps({"request_id": "vrq_opaque", "accepted": True}).encode() + ) + adapter = HTTPRegistrationVerificationAdapter( + base_url="http://verification", bearer_token="secret\n" + ) + receipt = adapter.request(RegistrationVerificationRequest( + registration_id="reg_1", + normalized_email="person@example.test", + preferred_username="person", + client_id="coulomb-social", + tenant="tenant:coulomb", + correlation_id="corr_1", + )) + self.assertEqual("vrq_opaque", receipt.request_id) + request = opener.call_args.args[0] + self.assertEqual("Bearer secret", request.headers["Authorization"]) + self.assertNotIn(b"return_to", request.data) + + @patch("user_engine.adapters.registration_verification.urlopen") + def test_consume_requires_registration_purpose_and_normalizes_email(self, opener): + opener.return_value = Response(json.dumps({ + "purpose": "public-registration", + "verification_id": "fvr_1", + "registration_id": "reg_1", + "email": "Person@Example.Test ", + "preferred_username": "person", + "client_id": "coulomb-social", + "tenant": "tenant:coulomb", + "source_system": "mail-verifier", + "assurance": {"mailbox_control": True}, + }).encode()) + adapter = HTTPRegistrationVerificationAdapter( + base_url="http://verification", bearer_token="secret" + ) + evidence = adapter.consume("x" * 32) + self.assertEqual("person@example.test", evidence.normalized_email) + self.assertTrue(evidence.assurance["mailbox_control"]) + + @patch("user_engine.adapters.registration_verification.urlopen") + def test_consume_rejects_wrong_purpose(self, opener): + opener.return_value = Response(json.dumps({ + "purpose": "password-reset" + }).encode()) + adapter = HTTPRegistrationVerificationAdapter( + base_url="http://verification", bearer_token="secret" + ) + with self.assertRaisesRegex(RuntimeError, "wrong purpose"): + adapter.consume("x" * 32) diff --git a/tests/test_web.py b/tests/test_web.py index 4086bab..da14a33 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -11,7 +11,13 @@ from user_engine.domain import ( OnboardingStepStatus, OnboardingTriggerType, SubsystemHandoff, ) from user_engine.oidc import BrowserSession, OIDCClient -from user_engine.ports import IdentityDriftResult, ProvisioningResult, TenantProvisioningResult +from user_engine.ports import ( + IdentityDriftResult, + ProvisioningResult, + RegistrationVerificationReceipt, + TenantProvisioningResult, + VerifiedRegistrationApplicant, +) from user_engine.service import UserEngineService from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims from user_engine.web import PortalApplication @@ -176,6 +182,112 @@ class PortalApplicationTests(unittest.TestCase): self.assertEqual("corr_test", result["headers"]["X-Request-ID"]) self.assertEqual("factor_pending", json.loads(payload)["status"]) + def test_public_registration_start_and_verified_factor_are_bound(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"} + ) + started, payload = invoke( + self.app, + "/api/v1/public/registrations", + method="POST", + body={ + "username": "New.Person", + "email": "New.Person@Example.Test", + "display_name": "New Person", + "client_id": "coulomb-social", + "tenant": "tenant:coulomb", + "return_to": "https://evil.example", + }, + ) + self.assertEqual("202 Accepted", started["status"]) + self.assertEqual({"status": "verification_requested"}, json.loads(payload)) + self.assertEqual("new.person", verifier.requested.preferred_username) + self.assertEqual("new.person@example.test", verifier.requested.normalized_email) + + verifier.registration_id = verifier.requested.registration_id + verified, payload = invoke( + self.app, + "/api/v1/public/registrations/verify", + method="POST", + body={"handle": "x" * 32}, + ) + self.assertEqual("303 See Other", verified["status"]) + self.assertEqual( + "https://kc.example/setup/password?token=opaque", + verified["headers"]["Location"], + ) + self.assertEqual(1, self.app.service.operability_snapshot().metrics["users"]) + session = self.app.service.store.registration_session(verifier.registration_id) + self.assertEqual("completed", session.status.value) + identities = self.app.service.store.identities_for_user(session.user_id) + self.assertTrue(any( + identity.issuer == "https://kc.example" + and identity.subject == "new.person" + for identity in identities + )) + provision = self.app.provisioning.requests[-1] + self.assertEqual(("user",), provision.roles) + self.assertEqual("new.person", provision.preferred_username) + self.assertEqual( + f"public-registration-{verifier.registration_id}", + provision.idempotency_key, + ) + + def test_public_registration_rejects_untrusted_password_setup_origin(self): + verifier = FakeRegistrationVerification() + self.app.registration_verification = verifier + self.app.provisioning = FakeProvisioning( + password_setup_url="https://evil.example/setup" + ) + 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"} + ) + invoke( + self.app, + "/api/v1/public/registrations", + method="POST", + body={ + "username": "person", + "email": "person@example.test", + "client_id": "coulomb-social", + "tenant": "tenant:coulomb", + }, + ) + verifier.registration_id = verifier.requested.registration_id + result, _ = invoke( + self.app, + "/api/v1/public/registrations/verify", + method="POST", + body={"handle": "x" * 32}, + ) + self.assertEqual("400 Bad Request", result["status"]) + + def test_public_registration_rejects_unregistered_client(self): + self.app.registration_verification = FakeRegistrationVerification() + self.app.registration_clients = frozenset({"coulomb-social"}) + self.app.registration_tenants = frozenset({"tenant:coulomb"}) + result, _ = invoke( + self.app, + "/api/v1/public/registrations", + method="POST", + body={ + "username": "person", + "email": "person@example.test", + "client_id": "unknown", + "tenant": "tenant:coulomb", + }, + ) + self.assertEqual("400 Bad Request", result["status"]) + def test_provision_api_links_provider_subject(self): self.app.provisioning = FakeProvisioning() created, payload = invoke( @@ -602,17 +714,43 @@ class PortalApplicationTests(unittest.TestCase): self.assertIsNotNone(user.profile_completed_at) -class FakeProvisioning: +class FakeRegistrationVerification: def __init__(self): + self.requested = None + self.registration_id = None + + def request(self, request): + self.requested = request + return RegistrationVerificationReceipt(request_id="vrq_test") + + def consume(self, opaque_handle): + return VerifiedRegistrationApplicant( + verification_id="fvr_test", + registration_id=self.registration_id, + normalized_email=self.requested.normalized_email, + preferred_username=self.requested.preferred_username, + client_id=self.requested.client_id, + tenant=self.requested.tenant, + source_system="mail-verifier", + assurance={"mailbox_control": True}, + display_name=self.requested.display_name, + ) + + +class FakeProvisioning: + def __init__(self, password_setup_url="https://kc.example/setup/password?token=opaque"): self.actions = [] + self.requests = [] + self.password_setup_url = password_setup_url def provision(self, request): + self.requests.append(request) self.actions.append(("provision", request.primary_email)) return ProvisioningResult( provider="netkingdom-lldap", - external_subject=request.primary_email.split("@")[0], + external_subject=request.preferred_username or request.primary_email.split("@")[0], status="password_setup_required", - password_setup_url="https://kc.example/setup/password?token=opaque", + password_setup_url=self.password_setup_url, ) def suspend(self, *, external_subject, idempotency_key, correlation_id): 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 ef68e70..c6cc21b 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 @@ -49,6 +49,22 @@ slice added `RegistrationVerificationPort`, bound request/receipt/evidence types, and a fail-closed HTTP adapter with purpose checking and normalized mailbox evidence. Adapter tests and the full 123-test suite pass. +Second code slice adds explicit allow-listed anonymous endpoints: +`POST /api/v1/public/registrations` returns only a generic 202 status, while +`POST /api/v1/public/registrations/verify` consumes purpose-bound evidence and +attaches the verified email factor. Persisted registration sessions bind the +random applicant subject, canonical username, client, and tenant. Evidence +mismatch fails closed, browser-supplied return URLs are ignored, and this +transition creates neither a user nor an LLDAP identity. Full suite now passes +125 tests with 3 environment-dependent skips. + +Third code slice completes the verified happy path: create the local user, +call identity-provisioner with deterministic registration idempotency and only +the baseline `user` role, link the returned subject under the configured +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. + ## T02 - Orchestrate provider identity creation ```task