Skip to content
22 changes: 22 additions & 0 deletions .changeset/25060-signup-existing-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/plugin-email': patch
---

feat(plugin-auth, plugin-email): under the `open` and `email_domain` postures, a sign-up for an address that already has an account answers like a new sign-up and mails that address, while mail is really delivered

Clause-②: yes
Metadata-protocol: none

The public email-and-password sign-up changes its answer in one case. Under the `open` and `email_domain` audience postures, a sign-up for an address that already has an account was refused explicitly. Now, when the deployment's mail transport really delivers, it answers exactly as a successful sign-up does on that deployment, and the address's holder receives a mail saying the address already has an account, with a link to sign in and a link to reset the password. Nothing is written: no new user, credential or session. The caller can no longer tell from the answer whether the address was registered.

- **When the explicit refusal stays, unchanged.**
- The mail would not be delivered: the email service says its transport is not live (`IEmailService.isConfigured()` answers `false`, which plugin-email answers for its `log` transport in every environment), the email service does not implement `isConfigured()`, or no email service is wired. A success with no mail behind it is what the refusal replaced, so it is never answered.
- The posture is `invite_only`. A stranger still learns nothing there, and an invited person whose address already has an account is still refused.
- The posture is `open` with email verification declared off (and auto sign-in left on). A new sign-up there is signed in at once, and an existing address cannot get that answer without a session for someone else's account.
- **Read at sign-up time.** The transport is asked on every sign-up, so a transport saved from the Mail settings applies to the next sign-up without a restart.
- **The mail.** plugin-email ships a new built-in template, `auth.existing_account_sign_up`, in `en-US`, `zh-CN`, `ja-JP` and `es-ES`. It is registered like the other built-in auth templates, so an app declaration or a Studio save overrides it. Its variables are `user.name` (optional), `user.email`, `signInUrl`, `resetPasswordUrl` and `appName` (optional). The two links are the Console's sign-in and forgot-password pages and carry no token. `BUILTIN_AUTH_TEMPLATES` grows from 28 rows to 32.
- **The mail's language.** The recipient's stored language (`sys_user.locale`), then the deployment default. The language of the browser that asked is never used, because the person signing up may not be the address's holder.
- **For a client of the sign-up door.** In the configurations above, a screen that showed an "already registered" message on the refusal now receives the success answer instead, and should show its usual "check your email" step.
- **A send that fails after the answer.** The answer is still the success, and the server logs one error-level line saying the mail was not sent. A verification mail that fails for a new address leaves the same gap.
- **Unchanged.** A sign-up for a new address, every refusal the audience posture raises, and every other auth mail.
78 changes: 42 additions & 36 deletions content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -609,11 +609,12 @@ this step.
**Two remedies that look like they work, measured:**

- **Opening the audience posture is not enough on its own.** With the posture
widened to `email_domain`, a seeded person's own registration is refused
`422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` and nothing is written — the same
answer the `invite_only` default already gives for an address the directory
holds, so the widened posture buys that person no login and their sign-in
stays `401`. A fresh address does get an account, but every posture other
widened to `email_domain`, a seeded person's own registration writes nothing
and buys them no login. It is refused explicitly, as the `invite_only` default
already refuses an address the directory holds — or, while the mail transport
is live, it gets the same answer a new registration gets and the address is
mailed that it already has an account (see the next section). Either way their
sign-in still fails. A fresh address does get an account, but every posture other
than `invite_only` forces email verification on (unless an `open` deployment
has itself declared it off — see
[Email verification under each posture](#email-verification-under-each-posture)),
Expand Down Expand Up @@ -641,37 +642,42 @@ a populated deployment, posting to `/api/v1/auth/sign-up/email`:
|:---|:---|
| `invite_only` (default) | **Nothing.** A registered address and an unknown one both answer `403 SELF_REGISTRATION_CLOSED` — same body bytes, same headers. |
| `email_domain` | **Only inside a domain you allowlisted.** Any address outside the list answers `403 EMAIL_DOMAIN_NOT_ALLOWED`, registered or not, so a caller must already hold an address on an allowlisted domain before they learn anything. |
| `open` | **Whether any address is already registered.** A registered address answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`; an unregistered one answers `200`. |

**On `open`, that disclosure is by design.** Anyone who can reach the endpoint
can test an address and learn whether it belongs to a user of this deployment,
one address per request, holding no account of their own. On an
internet-facing deployment that is an address-enumeration oracle. It is a cost
of the posture rather than an oversight: the endpoint refuses **honestly**, and
what that refusal replaced was worse for everyone except the enumerator. A duplicate
sign-up used to answer `200` carrying a freshly minted user id for a row that
was never written — no account, no verification mail, and the person's next
sign-in `401` with nothing anywhere explaining it.

**That silence was never a real secret, either.** The auth engine hashed the
password on both lanes to level the timing and stopped there: a genuine
registration also writes rows and dispatches a verification mail, so the mail
that never arrives — and the account that can never sign in — already told a
patient caller what the `422` now states outright. The explicit refusal made an
existing disclosure legible; it did not create it. `invite_only` and
`email_domain` are untouched by it: the uniqueness refusal is reached only for
a caller the posture has already admitted, so a stranger on the default still
gets `403 SELF_REGISTRATION_CLOSED` and learns nothing.

**If you need `open` without the disclosure, ask for it.** better-auth, the
auth engine underneath, ships the designed alternative: `onExistingUserSignUp`,
a hook that pairs a generic sign-up response with a notification to the
address's real holder — "someone tried to register with your email". It is
deliberately **not wired** here, because turning it on trades the honest
refusal above for a silent success, which is the shape that behaviour exists to
remove. If you run a real internet-facing `open` deployment and want that
trade, open an issue saying so: it is a per-deployment decision, waiting on a
deployment that needs it.
| `open` | **Nothing, while the mail transport is live.** A registered address gets the same answer as a new sign-up, nothing is written, and the address's holder is mailed that it already has an account. Without a live transport, or with email verification declared off and auto sign-in left on, a registered address is refused explicitly, so the answer tells a caller that the address is registered. |

**What decides it on `open` and `email_domain`.** A sign-up for an address that
already has an account gets the same answer as a successful new sign-up, and
writes nothing, when all three of these hold:

- The posture is `open` or `email_domain`. `invite_only` is unchanged: the
uniqueness question is reached only for a caller the posture has already
admitted, so a stranger on the default still learns nothing.
- Email verification is on, or auto sign-in is off. Only then does a new
sign-up's own answer carry no session, so an existing address can get the
same answer without a session for someone else's account.
- The email service's transport is live: `IEmailService.isConfigured()` answers
true. It is asked at every sign-up, so a transport saved from the mail
settings applies to the next one. plugin-email's `log` transport is never
live, in any environment, and an email service without that member counts as
not live.

The address's holder then receives a mail saying the address already has an
account, with links to the sign-in and forgot-password pages and no token. It is
the built-in `auth.existing_account_sign_up` template, overridable like the
other auth mails, in the holder's stored language or else the deployment
default. The platform wires better-auth's `onExistingUserSignUp` hook to send
it, and decides first, before the auth engine answers, whether that mail can be
promised.

**Otherwise the refusal stays explicit.** With no live transport, on
`invite_only`, or on `open` with verification declared off and auto sign-in
left on, a sign-up for a registered address is refused outright. On `open`
that refusal tells anyone who can reach the endpoint whether an address belongs
to a user of this deployment, one address per request — an address-enumeration
oracle on an internet-facing deployment. It stays because a success answer is
honest only when a mail follows it: what the refusal replaced was a duplicate
sign-up answered as a success for a row that was never written, with no mail
and a next sign-in that failed with nothing explaining it. To close the oracle
on `open`, keep verification on and connect a mail transport.

### Email verification under each posture

Expand Down
Loading
Loading