feat(auth): pre-persist registration webhook so rejected signups never leave orphaned Kratos identities - #503
Merged
Conversation
…r leave orphaned Kratos identities The after-registration web_hook runs with `response.parse: false`, which in Kratos v1.0.0 is a post-persist hook: the identity is committed before the api is called, so every rejection inside createAccountWithPhoneIdentifier (DuplicateKeyForPersistError, IbexError, InvalidCarrierTypeForPhoneMetadataError) or a pod dying mid-hook leaves a logged-in identity with no account. Those orphans crash-looped every api replica on 2026-09-01. Add a validation-only pre-persist hook, POST /kratos/preregistration, for a web_hook configured `response.parse: true`: Kratos then calls it before persisting, and a 4xx with the `messages` body aborts the sign-up with nothing written. It checks the callback secret, schema, phone, carrier metadata and that no users document already binds the phone; it never writes and never inspects identity_id (the nil uuid at that point). The existing post-persist /registration hook still creates the account, so a failed Kratos persist can never strand a Mongo account either. When Kratos hands the rejected flow back as a 400, map our message ids to PhoneAlreadyExistsError / PhoneNotAllowedForRegistrationError instead of the blanket LikelyUserAlreadyExistError; unrecognised 400s keep that reading. Deploy the api before the charts/deployments hook config, and TEST first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
- PhoneAlreadyRegisteredError: the hook's "already registered" id no longer
maps to PhoneAlreadyExistsError, whose GraphQL text ("one phone per
account") was written for the add-phone flow. Sign-up callers have no
account; the new error reads "This phone number is already registered.
Contact support if you can't sign in".
- RegistrationHookFailedError (Critical, KratosError): ids 4100003/4100401/
4100500 mean Kratos config and the api disagree or infra is down. They now
land in the unexpected-error catch-all under that name instead of being
reported as a phone-policy answer or as "user already exists".
- Hook route: RegistrationPayloadValidationError logs at error, not warn.
- Carrier errors now extend PhoneMetadataValidationError, so the route's
parent-class case is real instead of dead.
- kratos-hook-messages: state the actual collision argument (Kratos v1.0.0
text/id.go allocates 10000-wide blocks up to 4070000 and 5000000; nothing at
4100000), and pin the block in a test.
- validate-preregistration-payload.spec: assert the repository's update is
never called instead of inspecting the mock's own shape.
- Drop the duplicate RegistrationPayloadValidator describe; the phoneMetadata
round-trip and invalid-carrier cases move to its own spec.
- Integration: drive the real self-service registration against the compose
Kratos with the hook routes served from jest. Asserts a phone bound to a
stale users doc and invalid carrier metadata are refused with the mapped
domain error and leave no identity behind, and that an accepted sign-up
still gets its account from the post-persist hook. Adds kratos to
integration-deps and a bats-tests network alias so Kratos can reach the
suite in CI.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…-rendered kratos.yml quickstart/dev/ory/kratos.yml is not derived from dev/ory/kratos.yml: re-render.sh vendir-syncs upstream galoy at the pinned ref and only rewrites the hosts, and both the Quickstart CI job and `make smoke-env-up` run `make re-render` before boot. Upstream still ships the /kratos/preregistration hook commented out, so the one environment that runs the real api container against a real Kratos kept post-persist-only hooks and kept minting the orphaned identities this branch exists to stop; a hand edit of the rendered file is overwritten on the next re-render. - quickstart/bin/splice-kratos-preregistration-hook.sh: after the host rewrite, insert the `response.parse: true` web_hook ahead of the /registration hook and retire upstream's commented-out draft. No-op when the hook is already there; exits 1 when the /registration anchor is missing or laid out differently, so a re-render can never silently drop the hook. POSIX awk/sed, verified on BSD awk (macOS) and mawk (ubuntu). - quickstart/bin/re-render.sh: call it. - quickstart/dev/ory/kratos.yml: regenerated with `make re-render` (ytt v0.55.1, vendir v0.46.1, galoy 6906f1b); only the hooks list changed. Kratos v1.0.0 boots on it. - test/flash/unit/dev/kratos-registration-hooks.spec.ts: both dev configs must run the pre-persist hook before the post-persist one and the session hook last (fails on the previous rendered file), plus the splice script's behaviour on upstream's input, idempotency and both refusal paths. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
…t instead of calling it present Round-3 review finding: the splice script's no-op branch only checked that a `url: .../kratos/preregistration` line existed. If upstream ever ships that hook with `parse: false` (post-persist), the script would report "already present" and Quickstart would run with the orphan-creating shape while every check stays green. The no-op now also requires `parse: true` within four lines of that url; anything else exits 1 with a message that says what to fix. Spec case flips the spliced hook's own parse line (not the comment) and asserts the refusal leaves the file untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8
Contributor
Author
|
Simon review: 3 rounds. Round 1 (2 blockers, 5 should-fixes) and round 2 (2 should-fixes) applied in 6f55b1a and 6c6f199 by the fix pass; round 3's last should-fix (the splice script's no-op branch accepting a non-pre-persist hook) in the commit above. Full unit suite on the branch: 229 suites / 2,474 tests green; tsc only the 2 pre-existing Express errors; eslint clean. 🤖 Generated with Claude Code |
# Conflicts: # test/flash/unit/graphql/error-map.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
On 2026-09-01 every
apireplica crash-looped on requests from identities that exist in Kratos but have no Mongo account (#499 covers the crash side). Those orphans are created by the registration webhook itself:http://api:4002/kratos/registrationis configuredresponse.parse: false, and in Kratos v1.0.0 (what prod runs) that is a post-persist hook — the identity is committed before the api is even called. Any rejection insidecreateAccountWithPhoneIdentifierstrands it. From today's api logs, in a few hours:DuplicateKeyForPersistError× 26 (ausersdocument already holding the phone)IbexError× 13InvalidCarrierTypeForPhoneMetadataError× 3 (a policy rejection raised after the identity was already committed)plus any pod dying mid-hook.
Kratos mechanics (verified in
ory/kratosatv1.0.0)selfservice/hook/web_hook.go:So
response.parse: true(or the deprecatedcan_interrupt: true) moves the hook before persistence.parseWebhookResponsethen defines the contract: status ≥ 400 must carry a JSON body{"messages":[{"instance_ptr":"#/traits/phone","messages":[{"id":<int>,"text":"…","type":"error"}]}]}and aborts the flow with those messages (nothing is persisted); a 200 must be JSON ({}is fine) or a 204; a non-JSON body on either path fails the registration with an opaque "webhook failed".response.ignoremust stayfalseon that hook. In the pre-persist hookctx.identity.idis the nil uuid (identity.NewIdentity()setsID: uuid.Nil; the persister assigns the real id).Why two hooks, not one
Moving the whole account write pre-persist would create the inverse orphan: a Mongo account (and a
usersdocument holding the phone) whose Kratos persist then fails — e.g. two concurrent registrations for the same number — which locks that phone out of ever registering. So:POST /kratos/preregistration(response.parse: true) — validation only, no writes: callback secret, schema, phone parse, carrier metadata (PhoneMetadataValidator), and "nousersdocument already binds this phone" (the DuplicateKey source).identity_idis never inspected./registrationhook unchanged — still creates the account once the identity exists. What it can still leave behind (infra failure mid-hook) is what fix(auth): never crash the api on an orphaned kratos identity; self-heal missing accounts #499's session self-heal repairs.This is the design the team sketched in
dev/ory/kratos.yml(the commentedpreregistrationhook).Changes
src/servers/event-handlers/kratos.ts— new routePOST /kratos/preregistration; always JSON./registrationuntouched.src/app/authentication/validate-preregistration-payload.ts— read-only app function.src/domain/authentication/registration-payload-validator.ts—PreRegistrationPayloadValidator(no identity id); the phone/metadata checks are shared with the existing validator, whose behaviour is unchanged.src/domain/authentication/kratos-hook-messages.ts— the hook contract: message ids, texts, body builder, and the id extractor used on the client side.src/services/kratos/registration-flow-error.ts— when Kratos hands the rejected flow back as a 400, map our ids back to domain errors: 4100002 →PhoneAlreadyRegisteredError(INVALID_INPUT, sign-up wording: "This phone number is already registered. Contact support if you can't sign in" — deliberately notPhoneAlreadyExistsError, whose "one phone per account" text belongs to the add-phone-to-account flow); 4100001 →PhoneNotAllowedForRegistrationError; 4100003 / 4100401 / 4100500 →RegistrationHookFailedError(ErrorLevel.Critical: malformed hook payload, bad callback secret or repository failure is a deploy defect, so it goes to the unexpected-error catch-all and is never reported as a phone-policy answer); any other 400 keeps today'sLikelyUserAlreadyExistError. Wired intocreateIdentityWithSessionandcreateIdentityWithCookie(createIdentityNoSessionuses the admin API, which runs no self-service hooks, and is left alone).src/graphql/error-map.ts—PhoneNotAllowedForRegistrationError→ validation error "This phone number can't be used to sign up";PhoneAlreadyRegisteredError→ validation error "This phone number is already registered. Contact support if you can't sign in" (bothINVALID_INPUT, neither falls into the retry-and-contact-support catch-all);RegistrationHookFailedErrorjoins the unexpected-error catch-all.dev/ory/kratos.yml— real pre-registration hook entry, before the existing one.docker-compose.yml—integration-testsgets thebats-testsnetwork alias (anddepends_on: kratos) so the compose Kratos can reach the hook routes the integration suite serves from the test process.quickstart/bin/splice-kratos-preregistration-hook.sh+quickstart/bin/re-render.sh+quickstart/dev/ory/kratos.yml— the quickstart copy is not derived fromdev/ory/kratos.yml:re-render.shvendir-syncs upstream galoy at the pinned ref (which still ships the hook commented out) and only rewrites hosts, and both Quickstart CI andmake smoke-env-uprunmake re-renderbefore boot, so a hand edit is lost on the next render. The splice runs after the host rewrite, inserts theresponse.parse: truehook ahead of/registration, retires upstream's commented-out draft, is a no-op when the hook is already there, and fails the render (exit 1) when the/registrationanchor is missing or laid out differently. The rendered file is regenerated and committed; only its hooks list changed.Route contract
{}usersdocumentEvery non-200 body is
{"messages":[{"instance_ptr":"#/traits/phone","messages":[{"id":…,"text":…,"type":"error"}]}]}.Deploy order (matters)
values.yamland deploymentstf-modules/flash/kratos-postgres/postgres-values.tmpl.yaml): add theweb_hookforhttp://api:4002/kratos/preregistrationwithresponse: { parse: true }before the existing/registrationhook. Applying the config first would fail every registration with "webhook failed".Tests
Unit — new:
preregistration-payload-validator.spec.ts(incl. the nil-uuid payload),kratos-hook-messages.spec.ts,validate-preregistration-payload.spec.ts,servers/event-handlers/kratos-preregistration-route.spec.ts(exact bodies for 200/400×3/401/500),services/kratos/registration-flow-error.spec.ts(all five ids, the unmapped-400 fallback, non-400 passthrough),dev/kratos-registration-hooks.spec.ts(bothdev/ory/kratos.ymlandquickstart/dev/ory/kratos.ymlrun the pre-persist hook before the post-persist one with the session hook last — fails on the previous rendered quickstart file — plus the splice script on upstream's input, idempotency and both refusal paths); regression case inerror-map.spec.ts.Integration —
test/flash/integration/authentication/kratos-preregistration-hook.spec.tsdrives the real self-service registration flow against the compose Kratos v1.0.0 with the api's hook routes served from the test process, and asserts against Kratos' admin API what was stored: a phone already bound to ausersdocument is refused asPhoneAlreadyRegisteredErrorand no identity exists afterwards; carrier metadata the api rejects (carried throughtransient_payload→body.jsonnet) is refused asPhoneNotAllowedForRegistrationError, again with nothing persisted; an accepted sign-up still gets its account from the post-persist hook.Quickstart —
make re-render(ytt v0.55.1, vendir v0.46.1, galoy6906f1b) reproduces the committedquickstart/dev/ory/kratos.yml; the splice gives byte-identical output under BSD awk (macOS) and mawk (ubuntu 24.04); Kratos v1.0.0 boots on the rendered config (config validated at startup, admin/health/readyok, registration flow initialised).Follow-ups
usersdocuments (phone bound to a deleted identity) now get a clean "already registered" instead of an orphan; a reconcile for those is separate.InvalidCarrierTypeForPhoneMetadataErrorcould also be checked inisAllowedToOnboardbefore the flow is even started; this PR makes the hook the safety net either way.make re-renderon the pinned galoy ref also rewritesquickstart/galoy/**,quickstart/dev/apollo-federation/router.yaml,quickstart/graphql/public/schema.graphqlandquickstart/vendir.lock.ymlaway from what is committed (pre-existing drift, reverted here to keep this PR to the hook); worth a separate re-sync so CI and the checked-in tree agree.🤖 Generated with Claude Code
https://claude.ai/code/session_01EtQzyzQ38thfC2F8BLK4u8