From d6873b84ae43505ad5b75ed274647524fdcd76dc Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 10 Aug 2026 19:53:39 +0200 Subject: [PATCH] Make registration start idempotent --- src/user_engine/domain/models.py | 2 + src/user_engine/web.py | 53 ++++++++++++++++--- tests/test_web.py | 43 ++++++++++++++- ...gistration-and-jit-application-profiles.md | 8 +++ 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/user_engine/domain/models.py b/src/user_engine/domain/models.py index b0b41f0..2e69d4d 100644 --- a/src/user_engine/domain/models.py +++ b/src/user_engine/domain/models.py @@ -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) diff --git a/src/user_engine/web.py b/src/user_engine/web.py index 1b6009b..938ba3b 100644 --- a/src/user_engine/web.py +++ b/src/user_engine/web.py @@ -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'' for item in sorted(self.registration_clients) @@ -1340,6 +1380,7 @@ class PortalApplication:

We will verify your email before creating an identity.

+ diff --git a/tests/test_web.py b/tests/test_web.py index 2a0167f..91aea5b 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -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") 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 b047e7b..85834d3 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 @@ -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