From e4d932a38bda6b8150bb93f31ceecd6b74020797 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 14:07:05 +0000 Subject: [PATCH] fix(auth): make self-service "Resend Verification Email" work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BtooAsE7ebn5Tabwijtsg8 --- .changeset/fix-resend-verification-email.md | 15 ++ .../plugins/plugin-auth/src/auth-plugin.ts | 29 ++++ packages/plugins/plugin-auth/src/index.ts | 1 + .../src/send-verification-email.test.ts | 114 ++++++++++++++ .../src/send-verification-email.ts | 145 ++++++++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 .changeset/fix-resend-verification-email.md create mode 100644 packages/plugins/plugin-auth/src/send-verification-email.test.ts create mode 100644 packages/plugins/plugin-auth/src/send-verification-email.ts diff --git a/.changeset/fix-resend-verification-email.md b/.changeset/fix-resend-verification-email.md new file mode 100644 index 0000000000..84eefe7a9b --- /dev/null +++ b/.changeset/fix-resend-verification-email.md @@ -0,0 +1,15 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): make the self-service "Resend Verification Email" action work + +better-auth's stock `POST /send-verification-email` requires `{ email }` in the +body, but the `sys_user` `resend_verification_email` action (record-header +button, "email unverified" record alert, and record-section quick action) fires +with an empty body — so the request bounced with `[body.email] Invalid input: +expected string, received undefined` and the button was permanently broken. A +thin wrapper route now defaults the address to the authenticated caller's own +session email when the body omits it, then re-dispatches through the real route. +An explicitly-supplied `email` (admin / verify-screen path) passes through +untouched. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 354baa5bf5..21c579c096 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -20,6 +20,7 @@ import { import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { runSetInitialPassword } from './set-initial-password.js'; import { runRegisterSsoProviderFromForm, runRegisterSamlProviderFromForm, runRequestDomainVerification, runVerifyDomain } from './register-sso-provider.js'; +import { runResendVerificationEmail } from './send-verification-email.js'; import { authIdentityObjects, authPluginManifestHeader, @@ -1452,6 +1453,34 @@ export class AuthPlugin implements Plugin { } }); + // ──────────────────────────────────────────────────────────────────── + // Self-service resend of the email-verification link. SHADOWS better-auth's + // native `/send-verification-email` (registered before the catch-all below). + // + // The stock route 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 (no dialog, and the alert `action` reference + // can't carry params). That bounced with `[body.email] ... received + // undefined`, breaking every resend affordance. This wrapper defaults the + // address to the caller's own session email when the body omits it, then + // re-dispatches through the real route (via handleRequest, which bypasses + // this wrapper — no recursion). An explicit `email` passes through + // untouched, so the admin / verify-screen path is unchanged. + rawApp.post(`${basePath}/send-verification-email`, async (c: any) => { + try { + const { status, body } = await runResendVerificationEmail( + (req) => this.authManager!.handleRequest(req), + c.req.raw, + ); + return c.json(body, status); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + ctx.logger.error('[AuthPlugin] send-verification-email failed', err); + return c.json({ success: false, error: { code: 'internal', message: err.message } }, 500); + } + }); + // Register wildcard route to forward all auth requests to better-auth. // better-auth is configured with basePath matching our route prefix, so we // forward the original request directly — no path rewriting needed. diff --git a/packages/plugins/plugin-auth/src/index.ts b/packages/plugins/plugin-auth/src/index.ts index 97016388a2..bd3544e070 100644 --- a/packages/plugins/plugin-auth/src/index.ts +++ b/packages/plugins/plugin-auth/src/index.ts @@ -17,6 +17,7 @@ export * from './placeholder-email.js'; export * from './admin-import-users.js'; export * from './otp-send-guard.js'; export * from './register-sso-provider.js'; +export * from './send-verification-email.js'; export * from './objectql-adapter.js'; export * from './auth-schema-config.js'; export type { AuthConfig, AuthProviderConfig, AuthPluginConfig } from '@objectstack/spec/system'; diff --git a/packages/plugins/plugin-auth/src/send-verification-email.test.ts b/packages/plugins/plugin-auth/src/send-verification-email.test.ts new file mode 100644 index 0000000000..46004ba894 --- /dev/null +++ b/packages/plugins/plugin-auth/src/send-verification-email.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { runResendVerificationEmail } from './send-verification-email.js'; + +const SEND_URL = 'https://example.test/api/v1/auth/send-verification-email'; + +function makeRequest(body: unknown, headers: Record = {}): Request { + return new Request(SEND_URL, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +/** + * A fake better-auth universal handler. Answers `/get-session` from + * `sessionEmail` (null → unauthenticated 401) and records every + * `/send-verification-email` re-dispatch in `sent`. + */ +function makeHandle(opts: { sessionEmail?: string | null; sendStatus?: number; sendBody?: unknown }) { + const sent: Array<{ url: string; body: any }> = []; + const handle = vi.fn(async (req: Request): Promise => { + const url = new URL(req.url); + if (url.pathname.endsWith('/get-session')) { + if (opts.sessionEmail == null) { + return new Response('null', { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ user: { email: opts.sessionEmail } }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url.pathname.endsWith('/send-verification-email')) { + const body = await req.json().catch(() => ({})); + sent.push({ url: req.url, body }); + return new Response(JSON.stringify(opts.sendBody ?? { status: true }), { + status: opts.sendStatus ?? 200, + headers: { 'content-type': 'application/json' }, + }); + } + return new Response('not found', { status: 404 }); + }); + return { handle, sent }; +} + +describe('runResendVerificationEmail', () => { + it('defaults the address to the session email when the body omits it (one-click resend)', async () => { + const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' }); + const req = makeRequest({}, { cookie: 'better-auth.session_token=abc' }); + + const res = await runResendVerificationEmail(handle, req); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: true }); + expect(sent).toHaveLength(1); + expect(sent[0].body).toEqual({ email: 'me@example.test' }); + // The session cookie must ride along on the /get-session lookup. + const sessionCall = handle.mock.calls.find(([r]) => new URL((r as Request).url).pathname.endsWith('/get-session')); + expect((sessionCall![0] as Request).headers.get('cookie')).toContain('better-auth.session_token'); + }); + + it('passes an explicitly-supplied email straight through (no session lookup)', async () => { + const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' }); + const res = await runResendVerificationEmail(handle, makeRequest({ email: 'other@example.test' })); + + expect(res.status).toBe(200); + expect(sent).toHaveLength(1); + expect(sent[0].body).toEqual({ email: 'other@example.test' }); + // No /get-session round-trip when the email is already provided. + const sessionCalls = handle.mock.calls.filter(([r]) => new URL((r as Request).url).pathname.endsWith('/get-session')); + expect(sessionCalls).toHaveLength(0); + }); + + it('forwards an explicit callbackURL alongside the email', async () => { + const { handle, sent } = makeHandle({ sessionEmail: null }); + await runResendVerificationEmail(handle, makeRequest({ email: 'a@b.test', callbackURL: '/welcome' })); + expect(sent[0].body).toEqual({ email: 'a@b.test', callbackURL: '/welcome' }); + }); + + it('returns 400 when the body has no email and there is no session', async () => { + const { handle, sent } = makeHandle({ sessionEmail: null }); + const res = await runResendVerificationEmail(handle, makeRequest({})); + + expect(res.status).toBe(400); + expect((res.body as any).error?.code).toBe('invalid_request'); + // Never re-dispatched — nothing to send to. + expect(sent).toHaveLength(0); + }); + + it('tolerates a non-JSON body and falls back to the session email', async () => { + const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' }); + const res = await runResendVerificationEmail(handle, makeRequest('not-json{')); + expect(res.status).toBe(200); + expect(sent[0].body).toEqual({ email: 'me@example.test' }); + }); + + it('passes through the native error status/body on failure', async () => { + const { handle } = makeHandle({ + sessionEmail: 'me@example.test', + sendStatus: 429, + sendBody: { code: 'RATE_LIMITED', message: 'Too many requests' }, + }); + const res = await runResendVerificationEmail(handle, makeRequest({})); + expect(res.status).toBe(429); + expect(res.body).toEqual({ code: 'RATE_LIMITED', message: 'Too many requests' }); + }); + + it('ignores a blank email string and defaults to the session', async () => { + const { handle, sent } = makeHandle({ sessionEmail: 'me@example.test' }); + await runResendVerificationEmail(handle, makeRequest({ email: ' ' })); + expect(sent[0].body).toEqual({ email: 'me@example.test' }); + }); +}); diff --git a/packages/plugins/plugin-auth/src/send-verification-email.ts b/packages/plugins/plugin-auth/src/send-verification-email.ts new file mode 100644 index 0000000000..b5eb723024 --- /dev/null +++ b/packages/plugins/plugin-auth/src/send-verification-email.ts @@ -0,0 +1,145 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared `send-verification-email` (self-service resend) handler. + * + * better-auth's stock `POST /send-verification-email` REQUIRES `{ email }` in + * the body — it was designed for the post-signup verify screen where the user + * types (or re-supplies) the address to resend to. But the platform's + * self-service resend is a **one-click** affordance: the `resend_verification_email` + * action on `sys_user` (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 at all. So the request reached better-auth with no email + * and bounced with `[body.email] Invalid input: expected string, received + * undefined`, making the button permanently broken. + * + * This thin wrapper closes the gap by defaulting the address to the + * authenticated caller's own session email when the body omits it, then + * RE-DISPATCHING through the real `/send-verification-email` route (via the + * better-auth universal handler passed in) so token generation, the + * `sendVerificationEmail` callback, and rate limiting all still run — no logic + * is duplicated. An explicitly-supplied `email` (the admin / verify-screen + * path) passes through untouched, so no existing caller changes behaviour and + * no new enumeration surface is introduced. + * + * Like `runSetInitialPassword` / `runRegisterSsoProviderFromForm`, it is the + * single source of truth for the two mount points that must stay in lockstep: + * the full `AuthPlugin` (self-host / OSS host kernel) and the cloud + * `AuthProxyPlugin` (per-environment runtime). + */ + +import type { AuthRequestHandler } from './register-sso-provider.js'; + +export interface ResendVerificationEmailResult { + /** HTTP status to return to the caller. */ + status: number; + /** JSON body forwarded to the client (native better-auth body on the happy path). */ + body: unknown; +} + +const trimStr = (v: unknown): string => (typeof v === 'string' ? v.trim() : ''); + +/** + * Resolve the caller's own email by re-dispatching a `/get-session` through the + * same better-auth handler (best-effort). Returns `undefined` when there is no + * session or the lookup fails, so callers fall back to a 400 "email required". + * `sendUrl` is the resolved `…/send-verification-email` URL; we swap the + * trailing path for `…/get-session` on the same origin/basePath. + */ +async function resolveSessionEmail( + handle: AuthRequestHandler, + sendUrl: string, + headers: Headers, +): Promise { + try { + const sessionUrl = sendUrl.replace(/\/send-verification-email$/, '/get-session'); + if (sessionUrl === sendUrl) return undefined; + const h = new Headers({ accept: 'application/json' }); + const cookie = headers.get('cookie'); + if (cookie) h.set('cookie', cookie); + const authz = headers.get('authorization'); + if (authz) h.set('authorization', authz); + const resp = await handle(new Request(sessionUrl, { method: 'GET', headers: h })); + if (!resp.ok) return undefined; + const data: any = await resp.json().catch(() => null); + // customSession shapes the payload as `{ user, session }`; be tolerant of + // a nested `session.user` too. + const email = data?.user?.email ?? data?.session?.user?.email; + return typeof email === 'string' && email.length > 0 ? email : undefined; + } catch { + return undefined; + } +} + +/** + * Run a self-service-tolerant `send-verification-email`. + * + * @param handle the better-auth universal handler (`AuthManager.handleRequest` + * on the host kernel, or the resolved per-env handler in the + * cloud proxy). Used to resolve the session and re-dispatch the + * filled body to the real `/send-verification-email` route. + * @param request the raw Web `Request` — its headers carry the caller's session + * cookie / bearer; its body MAY carry `{ email?, callbackURL? }`. + */ +export async function runResendVerificationEmail( + handle: AuthRequestHandler, + request: Request, +): Promise { + let body: any; + try { + body = await request.json(); + } catch { + body = {}; + } + + let email = trimStr(body?.email); + const callbackURL = trimStr(body?.callbackURL); + + let sendUrl: string; + let origin: string; + try { + const url = new URL(request.url); + origin = url.origin; + sendUrl = url.href; + } catch { + return { status: 400, body: { success: false, error: { code: 'invalid_request', message: 'Bad request URL' } } }; + } + + // No address supplied → this is the one-click self-service resend. Default to + // the authenticated caller's own email. + if (!email) { + email = (await resolveSessionEmail(handle, sendUrl, request.headers)) ?? ''; + } + if (!email) { + return { + status: 400, + body: { success: false, error: { code: 'invalid_request', message: 'email is required (sign in to resend to your own address)' } }, + }; + } + + const headers = new Headers({ 'content-type': 'application/json' }); + const cookie = request.headers.get('cookie'); + if (cookie) headers.set('cookie', cookie); + const authz = request.headers.get('authorization'); + if (authz) headers.set('authorization', authz); + headers.set('origin', request.headers.get('origin') || origin); + + // Re-dispatch to the real better-auth route (the universal handler bypasses + // this wrapper, so there is no recursion) with the resolved email. + const innerReq = new Request(sendUrl, { + method: 'POST', + headers, + body: JSON.stringify({ email, ...(callbackURL ? { callbackURL } : {}) }), + }); + + const resp = await handle(innerReq); + let parsed: unknown; + try { + const t = await resp.text(); + parsed = t ? JSON.parse(t) : { success: resp.ok }; + } catch { + parsed = { success: resp.ok }; + } + return { status: resp.status, body: parsed }; +}