Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/sign-up-mail-after-response.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/plugin-auth': patch
---
Expand All @@ -9,4 +9,4 @@

- **The rule.** `POST /api/v1/auth/sign-up/email` answers without waiting for any mail send, whether or not the address already has an account: the verification mail, and the notice to an address that already has an account, are built and sent after the response, as the password-reset mail already is.
- **A send that fails is logged on the server**, at error (`verification email was not delivered`; the notice keeps its own error line), and never reaches the caller, whose answer is the one a sent mail gets, as before.
- **Unchanged.** The answer's status and body; the sign-up rate limits; the mails themselves; the `/send-verification-email` resend, which still awaits its send and still reports one that failed; and a server-side `auth.api.signUpEmail` call, which carries no request and still awaits its sends.
- **Unchanged.** The answer's status and body; the sign-up rate limits; the mails themselves; a signed-in caller's `/send-verification-email` resend, which still awaits its send and still reports one that failed; and a server-side `auth.api.signUpEmail` call, which carries no request and still awaits its sends.
12 changes: 12 additions & 0 deletions .changeset/visitor-verification-resend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth): a verification-email resend from a visitor who is not signed in answers the same whatever the send does

Clause-②: no
Metadata-protocol: none

- **The rule.** `POST /api/v1/auth/send-verification-email` from a visitor who is not signed in answers the same whatever the send does. While the email transport is live it answers `{ "status": true }`: the verification mail is built and sent after the response, as the sign-up's verification mail is, and a send that fails is logged on the server at error (`verification email was not delivered`).
- **While the email transport is not live** (no email service, or one whose `isConfigured()` does not answer `true`, such as the `log` transport), every such resend is refused with `400 EMAIL_SERVICE_REQUIRED`, the code `/change-email` and the identity import already answer for a deployment with no email delivery. Its message names the fix (Settings → Mail), and the verify screens show it.
- **Unchanged.** A signed-in caller's resend for their own address, by session cookie or by bearer token, still awaits its send, still reports one that failed, and is not refused while the transport is not live. A server-side `auth.api.sendVerificationEmail` call, which carries no request, still awaits its send.
56 changes: 36 additions & 20 deletions packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,36 +70,44 @@ import { vendorClientAddressOptions } from './client-address-vendor.js';
import { PLATFORM_IDP_PROVIDER_ID, refuseImplicitAccountLink } from './implicit-account-linking.js';
import { isPlaceholderEmail } from './placeholder-email.js';
import { logAfterResponseSendFailure, sendAfterResponseOnPublicDoor } from './run-after-response.js';
import { isVisitorResendInEndpointContext } from './visitor-verification-resend.js';

/** The path better-auth declares for the email sign-up endpoint. */
const SIGN_UP_EMAIL_PATH = '/sign-up/email';

/**
* `send`, with the one call the sign-up endpoint makes handed to
* {@link sendAfterResponseOnPublicDoor}: a sign-up answers without waiting for
* any mail send, whether or not the address already has an account. Every
* other call is `send` itself, awaited exactly as before — the
* `/send-verification-email` resend among them, which reports a send that
* failed to its caller.
* `send`, with two of its calls handed to {@link sendAfterResponseOnPublicDoor}:
*
* - the one the sign-up endpoint makes: a sign-up answers without waiting for
* any mail send, whether or not the address already has an account;
* - the one the `/send-verification-email` resend makes for a visitor who is
* not signed in: that resend answers the same whatever the send does
* (`visitor-verification-resend.ts` states the rule).
*
* Their failure is logged on the server, never handed to the caller. Every
* other call is `send` itself, awaited exactly as before — a signed-in
* caller's resend among them, which reports a send that failed to its caller.
*
* The endpoint is the one better-auth runs the call inside, read by its
* declared path from the endpoint context (as the hooks read `ctx.path`),
* never from the request's URL. Outside any endpoint context the call is
* awaited.
* never from the request's URL; whether the caller is signed in is the session
* that endpoint resolved before it called. Outside any endpoint context the
* call is awaited.
*/
function sendAfterResponseOnSignUp<D>(
function sendAfterResponseOnSignUpAndVisitorResend<D>(
send: (data: D, request?: unknown) => Promise<unknown>,
onFailure: (error: unknown) => void,
): (data: D, request?: unknown) => Promise<void> {
const afterResponse = sendAfterResponseOnPublicDoor(send, onFailure);
return (data, request) => {
let path: unknown;
let answersFirst = false;
try {
path = tryGetCurrentAuthEndpointContext()?.path;
const endpoint = tryGetCurrentAuthEndpointContext();
answersFirst = endpoint?.path === SIGN_UP_EMAIL_PATH || isVisitorResendInEndpointContext(endpoint);
} catch {
path = undefined;
answersFirst = false;
}
return path === SIGN_UP_EMAIL_PATH ? afterResponse(data, request) : (send(data, request) as Promise<void>);
return answersFirst ? afterResponse(data, request) : (send(data, request) as Promise<void>);
};
}

Expand Down Expand Up @@ -583,16 +591,22 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
// makes, carrying its request, resolves at once and the mail is built
// and sent after the response, as the notice to an address that
// already has an account is (`onExistingUserSignUp` above); its
// failure is logged on the server. Every other call is awaited as
// before (`sendAfterResponseOnSignUp`) — the resend below among them.
sendVerificationEmail: sendAfterResponseOnSignUp(async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => {
// failure is logged on the server. The `/send-verification-email`
// resend of a visitor who is not signed in is sent the same way, so
// it answers the same whatever the send does
// (`visitor-verification-resend.ts`). Every other call is awaited as
// before (`sendAfterResponseOnSignUpAndVisitorResend`) — a signed-in
// caller's resend among them.
sendVerificationEmail: sendAfterResponseOnSignUpAndVisitorResend(async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => {
const email = this.getEmailService();
if (!email) {
// Verification is enabled (this callback only exists when it is)
// but no email transport is wired — a misconfiguration, not a
// transient blip. THROW so the explicit `/send-verification-email`
// resend endpoint (which awaits this) surfaces a real error
// instead of a false "email sent" success. Sign-up stays
// transient blip. THROW so a signed-in caller's
// `/send-verification-email` resend (which awaits this) surfaces a
// real error instead of a false "email sent" success; a visitor's
// resend is refused before it gets here while no transport is
// live (`EMAIL_SERVICE_REQUIRED`, the before-hook). Sign-up stays
// resilient regardless: better-auth runs the sendOnSignUp call
// through `runInBackgroundOrAwait`, which logs (never rethrows)
// a failure, so the account is still created and the user lands
Expand All @@ -606,7 +620,8 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
// Let send failures propagate (see above): sendTemplate THROWS on
// template/loader errors, and returns status:'failed' on transport
// errors — surface both so resend is honest and signup stays
// errors — surface both so a signed-in caller's resend is honest
// (a visitor's is logged on the server instead) and signup stays
// resilient via better-auth's background-task error handling.
// commit 35e94c96b — the stored rung, same ladder as the reset above. An
// admin re-triggering verification for a user is the same
Expand Down Expand Up @@ -770,6 +785,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
hasExistingUserFor: (...a) => this.hasExistingUserFor(...a),
isAuthGateActive: (...a) => this.isAuthGateActive(...a),
isBootstrapCreation: (...a) => this.isBootstrapCreation(...a),
isEmailDeliveryLive: (...a) => this.isEmailDeliveryLive(...a),
isOperatorProvisioning: (...a) => this.isOperatorProvisioning(...a),
isPlatformAdminUserId: (...a) => this.isPlatformAdminUserId(...a),
resolveActor: (...a) => this.resolveActor(...a),
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager-before-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
import { ORGANIZATION_DELETE_PATH } from './organization-delete.js';
import { carriesPrincipalSession } from './principal-session-mint.js';
import { snapshotRoleChangeGrants, UPDATE_MEMBER_ROLE_PATH } from './role-change-granted-set.js';
import { refuseVisitorResendWithoutLiveTransport } from './visitor-verification-resend.js';

/**
* The `AuthManager` members the function below reads — every one of them, and
Expand All @@ -95,6 +96,7 @@ export interface BeforeHookHost {
hasExistingUserFor: AuthManager['hasExistingUserFor'];
isAuthGateActive: AuthManager['isAuthGateActive'];
isBootstrapCreation: AuthManager['isBootstrapCreation'];
isEmailDeliveryLive: AuthManager['isEmailDeliveryLive'];
isOperatorProvisioning: AuthManager['isOperatorProvisioning'];
isPlatformAdminUserId: AuthManager['isPlatformAdminUserId'];
resolveActor: AuthManager['resolveActor'];
Expand Down Expand Up @@ -671,6 +673,19 @@ export async function globalBeforeHook(
if (roleChangeDeps) await snapshotRoleChangeGrants(ctx, roleChangeDeps);
}

// ── The verification-email resend of a visitor who is not signed in ──
// While no email transport is live, every such resend is refused here,
// before the endpoint runs, with one coded answer whatever the address
// (400 `EMAIL_SERVICE_REQUIRED`). While one is live the resend answers
// the same whatever the send does: its send runs after the response
// (`sendAfterResponseOnSignUpAndVisitorResend`). A signed-in caller's
// resend passes through untouched. `visitor-verification-resend.ts`
// states the rule and how a visitor is told apart.
await refuseVisitorResendWithoutLiveTransport(ctx, {
isEmailDeliveryLive: () => this.isEmailDeliveryLive(),
resolveActor: (c) => this.resolveActor(c),
});

// ── ADR-0069 D2: account lockout (gate) ─────────────────────
// Reject a sign-in for a locked identity BEFORE better-auth checks
// the password — a lock must hold even against the correct password.
Expand Down
Loading
Loading