Make registration start idempotent
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 19:53:39 +02:00
parent e0399cf235
commit d6873b84ae
4 changed files with 99 additions and 7 deletions

View file

@ -579,6 +579,8 @@ class RegistrationSession:
started_by_subject: str | None = None
applicant_username: str | None = None
client_id: str | None = None
start_idempotency_hash: str | None = None
start_request_hash: str | None = None
provisioning_resume_hash: str | None = None
correlation_id: str | None = None
created_at: datetime = field(default_factory=utc_now)

View file

@ -198,8 +198,10 @@ class PortalApplication:
if not self.public_registration or self.registration_verification is None:
raise NotFoundError("public registration is unavailable")
token = secrets.token_urlsafe(32)
idempotency_key = secrets.token_urlsafe(24)
return self._html(
start_response, self._registration_form(token), correlation_id,
start_response,
self._registration_form(token, idempotency_key), correlation_id,
extra_headers=[("Set-Cookie", self._registration_csrf_cookie(token))],
)
if path == "/register" and method == "POST":
@ -919,6 +921,34 @@ class PortalApplication:
if tenant not in self.registration_tenants:
raise ValidationError("tenant is not eligible for registration")
raw_idempotency_key = str(
body.get("idempotency_key") if browser
else environ.get("HTTP_IDEMPOTENCY_KEY", "")
)
if len(raw_idempotency_key) < 16 or len(raw_idempotency_key) > 256:
raise ValidationError("registration idempotency key is invalid")
idempotency_hash = hmac.new(
self.trusted_proxy_secret.encode(), raw_idempotency_key.encode(),
hashlib.sha256,
).hexdigest()
request_hash = hashlib.sha256(json.dumps({
"username": username, "email": email, "display_name": display_name,
"client_id": client_id, "tenant": tenant,
}, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
previous = next((
item for item in self.service.store.all_registration_sessions()
if item.start_idempotency_hash
and hmac.compare_digest(item.start_idempotency_hash, idempotency_hash)
), None)
if previous is not None:
if not previous.start_request_hash or not hmac.compare_digest(
previous.start_request_hash, request_hash
):
raise ConflictError("registration idempotency key was reused")
return self._registration_requested_response(
start_response, correlation_id, browser
)
applicant_subject = f"applicant_{secrets.token_hex(16)}"
actor = Actor(
issuer="urn:netkingdom:public-registration",
@ -937,6 +967,11 @@ class PortalApplication:
applicant_username=username,
client_id=client_id,
)
session = replace(
session, start_idempotency_hash=idempotency_hash,
start_request_hash=request_hash,
)
self.service.store.save_registration_session(session)
self.registration_verification.request(
RegistrationVerificationRequest(
registration_id=session.registration_id,
@ -948,6 +983,13 @@ class PortalApplication:
display_name=display_name,
)
)
return self._registration_requested_response(
start_response, correlation_id, browser
)
def _registration_requested_response(
self, start_response: StartResponse, correlation_id: str, browser: bool
) -> Iterable[bytes]:
if browser:
return self._html(
start_response,
@ -960,10 +1002,8 @@ class PortalApplication:
correlation_id,
)
return self._json(
start_response,
"202 Accepted",
{"status": "verification_requested"},
correlation_id,
start_response, "202 Accepted",
{"status": "verification_requested"}, correlation_id,
)
def _verify_public_registration(
@ -1325,7 +1365,7 @@ class PortalApplication:
+ identity,
)
def _registration_form(self, csrf_token: str) -> str:
def _registration_form(self, csrf_token: str, idempotency_key: str) -> str:
client_options = "".join(
f'<option value="{escape(item)}">{escape(item)}</option>'
for item in sorted(self.registration_clients)
@ -1340,6 +1380,7 @@ class PortalApplication:
<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)}">
<input type="hidden" name="idempotency_key" value="{escape(idempotency_key)}">
<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>

View file

@ -1,4 +1,5 @@
import io
import hashlib
import json
import re
import unittest
@ -53,6 +54,8 @@ def invoke(
if claims is not None:
environ["HTTP_X_VERIFIED_OIDC_CLAIMS"] = json.dumps(claims)
environ["HTTP_X_USER_ENGINE_PROXY_SECRET"] = marker
if path == "/api/v1/public/registrations" and method == "POST":
environ["HTTP_IDEMPOTENCY_KEY"] = hashlib.sha256(payload).hexdigest()
environ.update(headers or {})
captured = {}
@ -255,6 +258,9 @@ class PortalApplicationTests(unittest.TestCase):
self.assertIn(b"Create your account", html)
cookie = page["headers"]["Set-Cookie"]
token = re.search(rb'name="csrf_token" value="([^"]+)"', html).group(1).decode()
idempotency_key = re.search(
rb'name="idempotency_key" 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",
@ -262,7 +268,8 @@ class PortalApplicationTests(unittest.TestCase):
}, cookie=cookie)
self.assertEqual("403 Forbidden", denied["status"])
started, html = invoke(self.app, "/register", method="POST", form={
"csrf_token": token, "username": "new.person",
"csrf_token": token, "idempotency_key": idempotency_key,
"username": "new.person",
"email": "new@example.test", "display_name": "New Person",
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
}, cookie=cookie)
@ -436,6 +443,38 @@ class PortalApplicationTests(unittest.TestCase):
)
self.assertEqual("400 Bad Request", result["status"])
def test_public_registration_start_is_idempotent_and_key_is_payload_bound(self):
verifier = FakeRegistrationVerification()
self.app.registration_verification = verifier
self.app.registration_clients = frozenset({"coulomb-social"})
self.app.registration_tenants = frozenset({"tenant:coulomb"})
body = {
"username": "idem.person", "email": "idem@example.test",
"client_id": "coulomb-social", "tenant": "tenant:coulomb",
}
headers = {"HTTP_IDEMPOTENCY_KEY": "registration-key-123456789"}
first, _ = invoke(
self.app, "/api/v1/public/registrations", method="POST",
body=body, headers=headers,
)
registration_id = verifier.requested.registration_id
replay, _ = invoke(
self.app, "/api/v1/public/registrations", method="POST",
body=body, headers=headers,
)
conflicting, _ = invoke(
self.app, "/api/v1/public/registrations", method="POST",
body={**body, "email": "different@example.test"}, headers=headers,
)
sessions = self.app.service.store.all_registration_sessions()
self.assertEqual("202 Accepted", first["status"])
self.assertEqual("202 Accepted", replay["status"])
self.assertEqual("409 Conflict", conflicting["status"])
self.assertEqual(1, len(sessions))
self.assertEqual(1, verifier.request_count)
self.assertEqual(registration_id, sessions[0].registration_id)
self.assertNotIn("registration-key-123456789", repr(sessions[0]))
def test_public_registration_rate_limit_uses_peer_not_forwarded_header(self):
self.app.registration_verification = FakeRegistrationVerification()
self.app.registration_rate_limit = 2
@ -896,8 +935,10 @@ class FakeRegistrationVerification:
def __init__(self):
self.requested = None
self.registration_id = None
self.request_count = 0
def request(self, request):
self.request_count += 1
self.requested = request
return RegistrationVerificationReceipt(request_id="vrq_test")

View file

@ -102,6 +102,14 @@ atomically invalidate one another, replay fails closed, and no identity is
created from cancellation. The full user-engine suite passes 133 tests with
three environment-dependent skips; email-connect passes all 24 tests.
2026-08-10 idempotency completion: public API starts now require an
`Idempotency-Key`, while the browser form receives a cryptographically random
hidden key. user-engine persists only a keyed HMAC of that value plus a hash of
the normalized request. Exact retries return the same generic response without
creating another session or requesting another email; reuse with different
inputs returns 409. Raw keys and applicant fields are absent from idempotency
evidence. The full suite passes 134 tests with three external skips.
## T02 - Orchestrate provider identity creation
```task