Implement verified public registration flow
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 11:26:18 +02:00
parent b7a57a50a4
commit c36a09bded
12 changed files with 610 additions and 7 deletions

View file

@ -29,6 +29,7 @@
| workplan | USER-WP-0019 | finished | — | workplans/USER-WP-0019-provider-backed-postgres-conformance.md | | 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-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-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-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-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 | | 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-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-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-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 |

View file

@ -10,6 +10,9 @@ from user_engine.adapters.provisioning import HTTPIdentityProvisioningAdapter
from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter from user_engine.adapters.tenant_management import HTTPTenantManagementAdapter
from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter from user_engine.adapters.flex_auth import FlexAuthHTTPAdapter
from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter from user_engine.adapters.delivery import HTTPOutboxDeliveryAdapter
from user_engine.adapters.registration_verification import (
HTTPRegistrationVerificationAdapter,
)
__all__ = [ __all__ = [
"InMemoryUserEngineStore", "InMemoryUserEngineStore",
@ -20,4 +23,5 @@ __all__ = [
"HTTPTenantManagementAdapter", "HTTPTenantManagementAdapter",
"FlexAuthHTTPAdapter", "FlexAuthHTTPAdapter",
"HTTPOutboxDeliveryAdapter", "HTTPOutboxDeliveryAdapter",
"HTTPRegistrationVerificationAdapter",
] ]

View file

@ -36,6 +36,7 @@ class HTTPIdentityProvisioningAdapter:
"idempotency_key": request.idempotency_key, "idempotency_key": request.idempotency_key,
"correlation_id": request.correlation_id, "correlation_id": request.correlation_id,
"roles": request.roles, "roles": request.roles,
"preferred_username": request.preferred_username,
}) })
def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult: def suspend(self, *, external_subject: str, idempotency_key: str, correlation_id: str) -> ProvisioningResult:
@ -88,6 +89,7 @@ class HTTPIdentityProvisioningAdapter:
"idempotency_key": request.idempotency_key, "idempotency_key": request.idempotency_key,
"correlation_id": request.correlation_id, "correlation_id": request.correlation_id,
"roles": request.roles, "roles": request.roles,
"preferred_username": request.preferred_username,
"desired_status": desired_status, "desired_status": desired_status,
}) })
return IdentityDriftResult( return IdentityDriftResult(

View file

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

View file

@ -577,6 +577,8 @@ class RegistrationSession:
user_id: str | None = None user_id: str | None = None
netkingdom_id: str | None = None netkingdom_id: str | None = None
started_by_subject: str | None = None started_by_subject: str | None = None
applicant_username: str | None = None
client_id: str | None = None
correlation_id: str | None = None correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now) created_at: datetime = field(default_factory=utc_now)
updated_at: datetime = field(default_factory=utc_now) updated_at: datetime = field(default_factory=utc_now)

View file

@ -55,6 +55,43 @@ class ProvisioningRequest:
idempotency_key: str idempotency_key: str
correlation_id: str correlation_id: str
roles: tuple[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) @dataclass(frozen=True)
@ -133,6 +170,18 @@ class IdentityProvisioningPort(Protocol):
"""Converge managed provider state toward the requested lifecycle.""" """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): class UserEngineStore(Protocol):
"""Durable persistence boundary for user-engine service behavior. """Durable persistence boundary for user-engine service behavior.

View file

@ -8,6 +8,7 @@ from wsgiref.simple_server import make_server
from user_engine.adapters import ( from user_engine.adapters import (
FlexAuthHTTPAdapter, FlexAuthHTTPAdapter,
HTTPOutboxDeliveryAdapter, HTTPOutboxDeliveryAdapter,
HTTPRegistrationVerificationAdapter,
PostgresUserEngineStore, PostgresUserEngineStore,
VerifiedIdentityClaimsAdapter, VerifiedIdentityClaimsAdapter,
HTTPIdentityProvisioningAdapter, HTTPIdentityProvisioningAdapter,
@ -74,6 +75,33 @@ def create_application() -> PortalApplication:
), ),
tenant_management=tenant_management, tenant_management=tenant_management,
outbox_delivery=outbox_delivery, 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()
),
) )

View file

@ -384,6 +384,8 @@ class UserEngineService:
IdentityFactorType.EMAIL, IdentityFactorType.EMAIL,
), ),
correlation_id: str | None = None, correlation_id: str | None = None,
applicant_username: str | None = None,
client_id: str | None = None,
) -> RegistrationSession: ) -> RegistrationSession:
tenant_context = self.resolve_tenant_context(actor, tenant) tenant_context = self.resolve_tenant_context(actor, tenant)
correlation_id = correlation_id or new_id("corr") correlation_id = correlation_id or new_id("corr")
@ -406,6 +408,8 @@ class UserEngineService:
), ),
required_factor_types=required, required_factor_types=required,
started_by_subject=actor.subject, started_by_subject=actor.subject,
applicant_username=applicant_username,
client_id=client_id,
correlation_id=correlation_id, correlation_id=correlation_id,
) )
with self.store.transaction(): with self.store.transaction():

View file

@ -13,14 +13,28 @@ from dataclasses import asdict, is_dataclass
from enum import Enum from enum import Enum
from html import escape from html import escape
import json import json
import re
import secrets import secrets
from typing import Any, Callable, Iterable, Mapping 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.errors import AuthorizationDenied, ConflictError, NotFoundError, ValidationError
from user_engine.oidc import OIDCClient, cookie_value 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 from user_engine.service import PLATFORM_TENANT, UserEngineService
StartResponse = Callable[[str, list[tuple[str, str]]], Any] StartResponse = Callable[[str, list[tuple[str, str]]], Any]
@ -54,6 +68,11 @@ class PortalApplication:
provisioning: IdentityProvisioningPort | None = None, provisioning: IdentityProvisioningPort | None = None,
tenant_management: TenantManagementPort | None = None, tenant_management: TenantManagementPort | None = None,
outbox_delivery: Callable[[Any], None] | 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: ) -> None:
if len(trusted_proxy_secret) < 24: if len(trusted_proxy_secret) < 24:
raise ValueError("trusted proxy secret must contain at least 24 characters") raise ValueError("trusted proxy secret must contain at least 24 characters")
@ -65,6 +84,13 @@ class PortalApplication:
self.provisioning = provisioning self.provisioning = provisioning
self.tenant_management = tenant_management self.tenant_management = tenant_management
self.outbox_delivery = outbox_delivery 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]: 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)}" 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) 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 == "/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) actor = self._actor(environ)
if path == "/api/v1/me" and method == "GET": 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) 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: def _actor(self, environ: Mapping[str, Any]) -> Any:
return self.service.identity_adapter.normalize(self._claims(environ)) 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: def _optional_actor(self, environ: Mapping[str, Any]) -> Any | None:
try: try:
return self._actor(environ) return self._actor(environ)

View file

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

View file

@ -11,7 +11,13 @@ from user_engine.domain import (
OnboardingStepStatus, OnboardingTriggerType, SubsystemHandoff, OnboardingStepStatus, OnboardingTriggerType, SubsystemHandoff,
) )
from user_engine.oidc import BrowserSession, OIDCClient 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.service import UserEngineService
from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims from user_engine.testing.fixtures import FixtureIdentityClaimsAdapter, human_actor_claims
from user_engine.web import PortalApplication 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("corr_test", result["headers"]["X-Request-ID"])
self.assertEqual("factor_pending", json.loads(payload)["status"]) 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): def test_provision_api_links_provider_subject(self):
self.app.provisioning = FakeProvisioning() self.app.provisioning = FakeProvisioning()
created, payload = invoke( created, payload = invoke(
@ -602,17 +714,43 @@ class PortalApplicationTests(unittest.TestCase):
self.assertIsNotNone(user.profile_completed_at) self.assertIsNotNone(user.profile_completed_at)
class FakeProvisioning: class FakeRegistrationVerification:
def __init__(self): 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.actions = []
self.requests = []
self.password_setup_url = password_setup_url
def provision(self, request): def provision(self, request):
self.requests.append(request)
self.actions.append(("provision", request.primary_email)) self.actions.append(("provision", request.primary_email))
return ProvisioningResult( return ProvisioningResult(
provider="netkingdom-lldap", 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", 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): def suspend(self, *, external_subject, idempotency_key, correlation_id):

View file

@ -49,6 +49,22 @@ slice added `RegistrationVerificationPort`, bound request/receipt/evidence
types, and a fail-closed HTTP adapter with purpose checking and normalized types, and a fail-closed HTTP adapter with purpose checking and normalized
mailbox evidence. Adapter tests and the full 123-test suite pass. 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 ## T02 - Orchestrate provider identity creation
```task ```task