Skip to content

Commit 9887465

Browse files
os-zhuangclaude
andauthored
fix(auth): make self-service "Resend Verification Email" work (#2793)
better-auth's stock POST /send-verification-email requires `{ email }` in the body, but the sys_user `resend_verification_email` action — the record-header button, the "email unverified" record alert, and the record-section quick action — fires with an EMPTY body (there is no dialog collecting an email, and the record-alert `action` reference cannot carry params). The request therefore reached better-auth with no email and bounced with: [body.email] Invalid input: expected string, received undefined making every resend affordance permanently broken. Add a thin wrapper route that shadows the native /send-verification-email (registered before the catch-all): when the body omits `email`, it defaults to the authenticated caller's own session email (resolved via /get-session), then re-dispatches through the real route via handleRequest (which bypasses the wrapper — no recursion) so token generation, the sendVerificationEmail callback, and rate limiting all still run. An explicitly-supplied `email` (admin / verify-screen path) passes through untouched, so no existing caller changes behaviour and no new enumeration surface is introduced. The logic lives in a shared, exported helper (mirroring runSetInitialPassword / runRegisterSsoProviderFromForm) so the cloud AuthProxyPlugin mount point can adopt it and stay in lockstep. Covered by 7 unit tests. Claude-Session: https://claude.ai/code/session_01BtooAsE7ebn5Tabwijtsg8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a5eba5 commit 9887465

5 files changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/plugin-auth": patch
3+
---
4+
5+
fix(auth): make the self-service "Resend Verification Email" action work
6+
7+
better-auth's stock `POST /send-verification-email` requires `{ email }` in the
8+
body, but the `sys_user` `resend_verification_email` action (record-header
9+
button, "email unverified" record alert, and record-section quick action) fires
10+
with an empty body — so the request bounced with `[body.email] Invalid input:
11+
expected string, received undefined` and the button was permanently broken. A
12+
thin wrapper route now defaults the address to the authenticated caller's own
13+
session email when the body omits it, then re-dispatches through the real route.
14+
An explicitly-supplied `email` (admin / verify-screen path) passes through
15+
untouched.

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { ensureDefaultOrganization } from './ensure-default-organization.js';
2121
import { runSetInitialPassword } from './set-initial-password.js';
2222
import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js';
23+
import { runResendVerificationEmail } from './send-verification-email.js';
2324
import {
2425
authIdentityObjects,
2526
authPluginManifestHeader,
@@ -1452,6 +1453,34 @@ export class AuthPlugin implements Plugin {
14521453
}
14531454
});
14541455

1456+
// ────────────────────────────────────────────────────────────────────
1457+
// Self-service resend of the email-verification link. SHADOWS better-auth's
1458+
// native `/send-verification-email` (registered before the catch-all below).
1459+
//
1460+
// The stock route REQUIRES `{ email }` in the body, but the `sys_user`
1461+
// `resend_verification_email` action — the record-header button, the
1462+
// "email unverified" record alert, and the record-section quick action —
1463+
// fires with an EMPTY body (no dialog, and the alert `action` reference
1464+
// can't carry params). That bounced with `[body.email] ... received
1465+
// undefined`, breaking every resend affordance. This wrapper defaults the
1466+
// address to the caller's own session email when the body omits it, then
1467+
// re-dispatches through the real route (via handleRequest, which bypasses
1468+
// this wrapper — no recursion). An explicit `email` passes through
1469+
// untouched, so the admin / verify-screen path is unchanged.
1470+
rawApp.post(`${basePath}/send-verification-email`, async (c: any) => {
1471+
try {
1472+
const { status, body } = await runResendVerificationEmail(
1473+
(req) => this.authManager!.handleRequest(req),
1474+
c.req.raw,
1475+
);
1476+
return c.json(body, status);
1477+
} catch (error) {
1478+
const err = error instanceof Error ? error : new Error(String(error));
1479+
ctx.logger.error('[AuthPlugin] send-verification-email failed', err);
1480+
return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500);
1481+
}
1482+
});
1483+
14551484
// Register wildcard route to forward all auth requests to better-auth.
14561485
// better-auth is configured with basePath matching our route prefix, so we
14571486
// forward the original request directly — no path rewriting needed.

packages/plugins/plugin-auth/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './placeholder-email.js';
1717
export * from './admin-import-users.js';
1818
export * from './otp-send-guard.js';
1919
export * from './register-sso-provider.js';
20+
export * from './send-verification-email.js';
2021
export * from './objectql-adapter.js';
2122
export * from './auth-schema-config.js';
2223
export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system';
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi } from 'vitest';
4+
import { runResendVerificationEmail } from './send-verification-email.js';
5+
6+
const SEND_URL = 'https://example.test/api/v1/auth/send-verification-email';
7+
8+
function makeRequest(body: unknown, headers: Record<string, string> = {}): Request {
9+
return new Request(SEND_URL, {
10+
method: 'POST',
11+
headers: { 'content-type': 'application/json', ...headers },
12+
body: typeof body === 'string' ? body : JSON.stringify(body),
13+
});
14+
}
15+
16+
/**
17+
* A fake better-auth universal handler. Answers `/get-session` from
18+
* `sessionEmail` (null → unauthenticated 401) and records every
19+
* `/send-verification-email` re-dispatch in `sent`.
20+
*/
21+
function makeHandle(opts: { sessionEmail?: string | null; sendStatus?: number; sendBody?: unknown }) {
22+
const sent: Array<{ url: string; body: any }> = [];
23+
const handle = vi.fn(async (req: Request): Promise<Response> => {
24+
const url = new URL(req.url);
25+
if (url.pathname.endsWith('/get-session')) {
26+
if (opts.sessionEmail == null) {
27+
return new Response('null', { status: 200, headers: { 'content-type': 'application/json' } });
28+
}
29+
return new Response(JSON.stringify({ user: { email: opts.sessionEmail } }), {
30+
status: 200,
31+
headers: { 'content-type': 'application/json' },
32+
});
33+
}
34+
if (url.pathname.endsWith('/send-verification-email')) {
35+
const body = await req.json().catch(() => ({}));
36+
sent.push({ url: req.url, body });
37+
return new Response(JSON.stringify(opts.sendBody ?? { status: true }), {
38+
status: opts.sendStatus ?? 200,
39+
headers: { 'content-type': 'application/json' },
40+
});
41+
}
42+
return new Response('not found', { status: 404 });
43+
});
44+
return { handle, sent };
45+
}
46+
47+
describe('runResendVerificationEmail', () => {
48+
it('defaults the address to the session email when the body omits it (one-click resend)', async () => {
49+
const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' });
50+
const req = makeRequest({}, { cookie: 'better-auth.session_token=abc' });
51+
52+
const res = await runResendVerificationEmail(handle, req);
53+
54+
expect(res.status).toBe(200);
55+
expect(res.body).toEqual({ status: true });
56+
expect(sent).toHaveLength(1);
57+
expect(sent[0].body).toEqual({ email: 'me@example.test' });
58+
// The session cookie must ride along on the /get-session lookup.
59+
const sessionCall = handle.mock.calls.find(([r]) => new URL((r as Request).url).pathname.endsWith('/get-session'));
60+
expect((sessionCall![0] as Request).headers.get('cookie')).toContain('better-auth.session_token');
61+
});
62+
63+
it('passes an explicitly-supplied email straight through (no session lookup)', async () => {
64+
const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' });
65+
const res = await runResendVerificationEmail(handle, makeRequest({ email: 'other@example.test' }));
66+
67+
expect(res.status).toBe(200);
68+
expect(sent).toHaveLength(1);
69+
expect(sent[0].body).toEqual({ email: 'other@example.test' });
70+
// No /get-session round-trip when the email is already provided.
71+
const sessionCalls = handle.mock.calls.filter(([r]) => new URL((r as Request).url).pathname.endsWith('/get-session'));
72+
expect(sessionCalls).toHaveLength(0);
73+
});
74+
75+
it('forwards an explicit callbackURL alongside the email', async () => {
76+
const { handle, sent } = makeHandle({ sessionEmail: null });
77+
await runResendVerificationEmail(handle, makeRequest({ email: 'a@b.test', callbackURL: '/welcome' }));
78+
expect(sent[0].body).toEqual({ email: 'a@b.test', callbackURL: '/welcome' });
79+
});
80+
81+
it('returns 400 when the body has no email and there is no session', async () => {
82+
const { handle, sent } = makeHandle({ sessionEmail: null });
83+
const res = await runResendVerificationEmail(handle, makeRequest({}));
84+
85+
expect(res.status).toBe(400);
86+
expect((res.body as any).error?.code).toBe('invalid_request');
87+
// Never re-dispatched — nothing to send to.
88+
expect(sent).toHaveLength(0);
89+
});
90+
91+
it('tolerates a non-JSON body and falls back to the session email', async () => {
92+
const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' });
93+
const res = await runResendVerificationEmail(handle, makeRequest('not-json{'));
94+
expect(res.status).toBe(200);
95+
expect(sent[0].body).toEqual({ email: 'me@example.test' });
96+
});
97+
98+
it('passes through the native error status/body on failure', async () => {
99+
const { handle } = makeHandle({
100+
sessionEmail: 'me@example.test',
101+
sendStatus: 429,
102+
sendBody: { code: 'RATE_LIMITED', message: 'Too many requests' },
103+
});
104+
const res = await runResendVerificationEmail(handle, makeRequest({}));
105+
expect(res.status).toBe(429);
106+
expect(res.body).toEqual({ code: 'RATE_LIMITED', message: 'Too many requests' });
107+
});
108+
109+
it('ignores a blank email string and defaults to the session', async () => {
110+
const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' });
111+
await runResendVerificationEmail(handle, makeRequest({ email: ' ' }));
112+
expect(sent[0].body).toEqual({ email: 'me@example.test' });
113+
});
114+
});
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Shared `send-verification-email` (self-service resend) handler.
5+
*
6+
* better-auth's stock `POST /send-verification-email` REQUIRES `{ email }` in
7+
* the body — it was designed for the post-signup verify screen where the user
8+
* types (or re-supplies) the address to resend to. But the platform's
9+
* self-service resend is a **one-click** affordance: the `resend_verification_email`
10+
* action on `sys_user` (record header button, the "email unverified" record
11+
* alert, and the record-section quick action) fires with an EMPTY body — there
12+
* is no dialog collecting an email, and the record-alert `action` reference
13+
* cannot carry params at all. So the request reached better-auth with no email
14+
* and bounced with `[body.email] Invalid input: expected string, received
15+
* undefined`, making the button permanently broken.
16+
*
17+
* This thin wrapper closes the gap by defaulting the address to the
18+
* authenticated caller's own session email when the body omits it, then
19+
* RE-DISPATCHING through the real `/send-verification-email` route (via the
20+
* better-auth universal handler passed in) so token generation, the
21+
* `sendVerificationEmail` callback, and rate limiting all still run — no logic
22+
* is duplicated. An explicitly-supplied `email` (the admin / verify-screen
23+
* path) passes through untouched, so no existing caller changes behaviour and
24+
* no new enumeration surface is introduced.
25+
*
26+
* Like `runSetInitialPassword` / `runRegisterSsoProviderFromForm`, it is the
27+
* single source of truth for the two mount points that must stay in lockstep:
28+
* the full `AuthPlugin` (self-host / OSS host kernel) and the cloud
29+
* `AuthProxyPlugin` (per-environment runtime).
30+
*/
31+
32+
import type { AuthRequestHandler } from './register-sso-provider.js';
33+
34+
export interface ResendVerificationEmailResult {
35+
/** HTTP status to return to the caller. */
36+
status: number;
37+
/** JSON body forwarded to the client (native better-auth body on the happy path). */
38+
body: unknown;
39+
}
40+
41+
const trimStr = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
42+
43+
/**
44+
* Resolve the caller's own email by re-dispatching a `/get-session` through the
45+
* same better-auth handler (best-effort). Returns `undefined` when there is no
46+
* session or the lookup fails, so callers fall back to a 400 "email required".
47+
* `sendUrl` is the resolved `…/send-verification-email` URL; we swap the
48+
* trailing path for `…/get-session` on the same origin/basePath.
49+
*/
50+
async function resolveSessionEmail(
51+
handle: AuthRequestHandler,
52+
sendUrl: string,
53+
headers: Headers,
54+
): Promise<string | undefined> {
55+
try {
56+
const sessionUrl = sendUrl.replace(/\/send-verification-email$/, '/get-session');
57+
if (sessionUrl === sendUrl) return undefined;
58+
const h = new Headers({ accept: 'application/json' });
59+
const cookie = headers.get('cookie');
60+
if (cookie) h.set('cookie', cookie);
61+
const authz = headers.get('authorization');
62+
if (authz) h.set('authorization', authz);
63+
const resp = await handle(new Request(sessionUrl, { method: 'GET', headers: h }));
64+
if (!resp.ok) return undefined;
65+
const data: any = await resp.json().catch(() => null);
66+
// customSession shapes the payload as `{ user, session }`; be tolerant of
67+
// a nested `session.user` too.
68+
const email = data?.user?.email ?? data?.session?.user?.email;
69+
return typeof email === 'string' && email.length > 0 ? email : undefined;
70+
} catch {
71+
return undefined;
72+
}
73+
}
74+
75+
/**
76+
* Run a self-service-tolerant `send-verification-email`.
77+
*
78+
* @param handle the better-auth universal handler (`AuthManager.handleRequest`
79+
* on the host kernel, or the resolved per-env handler in the
80+
* cloud proxy). Used to resolve the session and re-dispatch the
81+
* filled body to the real `/send-verification-email` route.
82+
* @param request the raw Web `Request` — its headers carry the caller's session
83+
* cookie / bearer; its body MAY carry `{ email?, callbackURL? }`.
84+
*/
85+
export async function runResendVerificationEmail(
86+
handle: AuthRequestHandler,
87+
request: Request,
88+
): Promise<ResendVerificationEmailResult> {
89+
let body: any;
90+
try {
91+
body = await request.json();
92+
} catch {
93+
body = {};
94+
}
95+
96+
let email = trimStr(body?.email);
97+
const callbackURL = trimStr(body?.callbackURL);
98+
99+
let sendUrl: string;
100+
let origin: string;
101+
try {
102+
const url = new URL(request.url);
103+
origin = url.origin;
104+
sendUrl = url.href;
105+
} catch {
106+
return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } };
107+
}
108+
109+
// No address supplied → this is the one-click self-service resend. Default to
110+
// the authenticated caller's own email.
111+
if (!email) {
112+
email = (await resolveSessionEmail(handle, sendUrl, request.headers)) ?? '';
113+
}
114+
if (!email) {
115+
return {
116+
status: 400,
117+
body: { success: false, error: { code: 'invalid_request', message: 'email is required (sign in to resend to your own address)' } },
118+
};
119+
}
120+
121+
const headers = new Headers({ 'content-type': 'application/json' });
122+
const cookie = request.headers.get('cookie');
123+
if (cookie) headers.set('cookie', cookie);
124+
const authz = request.headers.get('authorization');
125+
if (authz) headers.set('authorization', authz);
126+
headers.set('origin', request.headers.get('origin') || origin);
127+
128+
// Re-dispatch to the real better-auth route (the universal handler bypasses
129+
// this wrapper, so there is no recursion) with the resolved email.
130+
const innerReq = new Request(sendUrl, {
131+
method: 'POST',
132+
headers,
133+
body: JSON.stringify({ email, ...(callbackURL ? { callbackURL } : {}) }),
134+
});
135+
136+
const resp = await handle(innerReq);
137+
let parsed: unknown;
138+
try {
139+
const t = await resp.text();
140+
parsed = t ? JSON.parse(t) : { success: resp.ok };
141+
} catch {
142+
parsed = { success: resp.ok };
143+
}
144+
return { status: resp.status, body: parsed };
145+
}

0 commit comments

Comments
 (0)