Skip to content

fix(auth): support Magic Auth sign-up for emails without a user - #52

Open
grayashh wants to merge 8 commits into
workos:mainfrom
grayashh:fix/magic-auth-signup
Open

fix(auth): support Magic Auth sign-up for emails without a user#52
grayashh wants to merge 8 commits into
workos:mainfrom
grayashh:fix/magic-auth-signup

Conversation

@grayashh

@grayashh grayashh commented Aug 6, 2026

Copy link
Copy Markdown
  • POST /user_management/magic_auth now creates the user when the email has none. Production creates it at code-creation time, not at authenticate: the live API's 201 already carries a user_id, the user is immediately listable with email_verified: false, and the email it sends uses the "Sign up" template. The emulator's 404 made AuthKit sign-up flows untestable.
  • Redeeming the code now flips email_verified to true, matching the live authenticate response for the same flow; the emulator previously left sign-up users unverified forever.
  • Probe transcript in Magic Auth code creation 404s for emails without an existing user, blocking the sign-up flow #50 — org-less sign-up yields a token with no org_id claim, pinned by a new test.

Closes #50

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds Magic Auth sign-up for previously unknown emails and verifies those users when they redeem their code.

  • Creates unverified users during Magic Auth code creation and emits the corresponding creation event.
  • Makes email validation, trimming, case-insensitive lookup, and uniqueness behavior consistent across related user-management flows.
  • Adds coverage for sign-up, authentication, invitations, password reset, SSO events, user listing, and seeded memberships.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/workos/routes/magic-auth.ts Adds validated Magic Auth user creation; the previously reported non-string persistence path is now guarded before insertion.
src/workos/helpers.ts Introduces shared email validation, normalization, matching, and lookup helpers used by the updated routes.
src/workos/routes/auth.ts Adds normalized email resolution and marks Magic Auth users verified as part of successful sign-in.
src/workos/routes/users.ts Aligns user creation and filtering with the new email validation and case-insensitive uniqueness behavior.
src/workos/config-validator.ts Enforces case-insensitive seeded-user uniqueness and membership references.

Reviews (6): Last reviewed commit: "fix(auth): finish resolving every email ..." | Re-trigger Greptile

Comment thread src/workos/routes/magic-auth.ts
grayashh and others added 7 commits August 6, 2026 19:38
Routing account creation through a lookup that was only ever a read
exposes what the read could afford to get wrong. An exact-match miss on
'User@x.test' vs 'user@x.test' used to be a harmless 404; once a miss
creates a user, the same miss forks the account in two. A bare presence
check on the email was likewise fine for a read and turns a typo into a
permanent ghost account once it can write.

Verifying the email up in the grant also meant persisting a change, and
firing user.updated, before the JWT template gate — the one thing that
comment is there to prevent, since a failed render is supposed to leave
no trace of a login that never completed. Folding it into the sign-in
write fixes the ordering and drops the second webhook per login.
Creation resolves the user case-insensitively but redemption matched the
email exactly, so the two halves disagreed about what the same address
means. A code requested for 'user@x.test' against a stored 'User@X.test' is
recorded under the stored casing, and authenticating with the address the
caller actually used then failed invalid_one_time_code — a 201 handing back
a code the request it answered could never spend. Recoverable only because
the 201 body carries the canonical email, which no client should have to
read to finish a flow it already had the address for.

The existing case-insensitivity test never redeemed, which is why it passed.
It does now, and fails without this change.

Also applies the typo guard to POST /user_management/users. That route
creates users too, so the same miss becomes the same unreachable account,
and holding one standard is what stops `{email: 'nope'}` being a 422 on one
path and a 201 on the other. It keeps the route's existing 422 convention
rather than magic auth's 400.

And documents both behavior changes: the endpoint no longer 404s an unknown
email, and redeeming a code verifies the email of any previously-unverified
user, not only ones it just created — which a suite pointed at this can be
surprised by in either direction.
Clarified behavior of Magic Auth and email handling.
Magic Auth stores the case it was handed, so once it creates users the
lookups that stayed exact-match strand the accounts it makes. A sign-up as
'Signup@y.test' could not be reached by the password grant, by login_hint, or
by password reset using the address the caller actually had — password reset
404d on an account that existed. All three resolve the way magic auth does
now.

POST /user_management/users deduped exactly, so 'User@x.test' and
'user@x.test' were both created and the two creation paths then disagreed
about which account an address names, with magic auth's resolver settling it
by insertion order. It answers 409 instead, which is what holding one
standard across the two creation paths actually requires.

A malformed email reported "email is required" for an address that was
supplied — the opposite of what happened, on the guard that exists to keep a
typo from becoming an account nothing can reach.

Also pins the sign-up's user.created event. A webhook consumer testing a
sign-up flow is much of why this endpoint creating a user is worth having.
Resolving an account case-insensitively means lowercasing the address, and
every path that does it type-asserted `email` first. That assertion was
survivable while a lookup by email could only miss — findOneBy returns
undefined for a number as readily as for an unknown address — but toLowerCase
throws, so the same request that used to come back a named 4xx now comes back
Internal Server Error: 401 invalid_credentials on the password grant, 404 on
password reset, and 400 invalid_code on the magic-auth grant all became 500
server_error. A server_error in a consumer's suite reads as a defect in the
emulator rather than a malformed request, which is the one thing an emulator
must never get wrong about its own inputs.

One guard for all of them, since the shape of the value is the same question
everywhere it is asked. Absence is handed back for the caller to report its
own way, so `{}` still says "email is required" while `{email: 123}` says what
it actually is — the distinction the magic auth handler already argued for and
then lost by routing a non-string into its presence check, where it reported
an address that was supplied as one that was missing.

The guard trims, which also closes a smaller gap in the same seam: creation
stored the trimmed address while the grants and password reset resolved the
raw one, so a padded copy of an address could not reach the account written
under it.
Six lookups by email still matched exactly, so the invariant the README
claimed held only where Magic Auth had been touched. Each one strands the
accounts Magic Auth now creates, because sign-up stores whatever case it was
handed:

GET /user_management/users?email= is what an SDK's listUsers({email}) maps to
— the way a caller finds the account a sign-up just made. Filtering exactly,
`Signup@X.test` was returned by its own casing and by nothing else. The test
added for sign-up creation leans on this filter and passed only because its
address is lowercase throughout.

Accepting an invitation resolved the recipient exactly, so for an account
stored under another case it enrolled nobody: 200, invitation.accepted
emitted, invitation spent, no membership, no error. The grant path already
compares the two addresses case-insensitively, so the two halves of one flow
disagreed about which person an address names.

SSO authentication events reported user_id: null for an account that existed.
The invitations email filter matched exactly. And a seeded membership joined
its user exactly, rejecting at startup a reference the running emulator would
have honoured.

Seeded `users` are now unique case-insensitively too, matching the 409 this
branch gave POST /user_management/users: a seed was otherwise the one door
left open to the pair of accounts no lookup by email can tell apart, which is
the state all of this exists to prevent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Magic Auth code creation 404s for emails without an existing user, blocking the sign-up flow

2 participants