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
15 changes: 15 additions & 0 deletions .changeset/fix-resend-verification-email.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/plugin-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
114 changes: 114 additions & 0 deletions packages/plugins/plugin-auth/src/send-verification-email.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): 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<Response> => {
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' });
});
});
145 changes: 145 additions & 0 deletions packages/plugins/plugin-auth/src/send-verification-email.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
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<ResendVerificationEmailResult> {
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 };
}