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
28 changes: 27 additions & 1 deletion src/core/auth/better-auth-email-hooks.runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ export interface EmailSenderForHooks {
export interface EmailHookErrorContext {
template: string;
to?: string;
kind: "email-verification" | "password-reset" | "welcome" | "invitation" | "new-device";
kind:
| "email-verification"
| "password-reset"
| "magic-link"
| "welcome"
| "invitation"
| "new-device";
}

export interface EmailHookRunnerOptions {
Expand Down Expand Up @@ -88,6 +94,12 @@ export interface ResetPasswordHookData {
token: string;
}

export interface MagicLinkHookData {
user: BetterAuthEmailUser;
url: string;
token: string;
}

export interface WelcomeHookData {
user: BetterAuthEmailUser;
}
Expand All @@ -113,6 +125,7 @@ export interface NewDeviceHookData {
export interface BetterAuthEmailHookRunner {
sendVerificationEmail(data: VerificationHookData): Promise<void>;
sendResetPassword(data: ResetPasswordHookData): Promise<void>;
sendMagicLink(data: MagicLinkHookData): Promise<void>;
sendWelcome(data: WelcomeHookData): Promise<void>;
sendInvitation(data: InvitationHookData): Promise<void>;
sendNewDevice(data: NewDeviceHookData): Promise<void>;
Expand Down Expand Up @@ -198,6 +211,19 @@ export function createEmailHookRunner(options: EmailHookRunnerOptions): BetterAu
data.token,
);
},
async sendMagicLink(data) {
await send(
"magic-link",
() =>
buildEmailHookPayload({
kind: "magic-link",
user: data.user,
url: data.url,
appName: options.appName,
}),
data.token,
);
},
async sendWelcome(data) {
await send(
"welcome",
Expand Down
19 changes: 19 additions & 0 deletions src/core/auth/better-auth-email-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface BetterAuthEmailUser {
export type EmailHookKind =
| "email-verification"
| "password-reset"
| "magic-link"
| "welcome"
| "invitation"
| "new-device";
Expand All @@ -58,6 +59,12 @@ interface PasswordResetHookInput extends BaseHookInput {
url: string;
}

interface MagicLinkHookInput extends BaseHookInput {
kind: "magic-link";
/** The one-time sign-in link Better-Auth issued. */
url: string;
}

interface WelcomeHookInput extends BaseHookInput {
kind: "welcome";
}
Expand Down Expand Up @@ -86,6 +93,7 @@ interface NewDeviceHookInput extends BaseHookInput {
export type EmailHookInput =
| VerificationHookInput
| PasswordResetHookInput
| MagicLinkHookInput
| WelcomeHookInput
| InvitationHookInput
| NewDeviceHookInput;
Expand Down Expand Up @@ -176,6 +184,17 @@ export function buildEmailHookPayload(input: EmailHookInput): EmailHookPayload {
vars: { recipientName, appName, resetUrl: url },
};
}
case "magic-link": {
const url = trim(input.url);
if (!url) {
throw new Error("better-auth-email-hooks: magic-link hook requires a non-empty url");
}
return {
template: "magic-link",
to: recipient,
vars: { recipientName, appName, magicLinkUrl: url },
};
}
case "welcome": {
return {
template: "welcome",
Expand Down
4 changes: 4 additions & 0 deletions src/core/auth/better-auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ import { isBetterAuthRateLimitEnabled } from "./rate-limit-flag.js";
...(features.authMethods.socialProviders.length > 0
? { socialProviders: pickSocialProviders(features) }
: {}),
// Magic-link (passwordless) — when enabled, buildBetterAuth wires
// the link delivery through the shared email-hook runner (the
// `magic-link` template). No caller closure needed.
...(features.magicLink.enabled ? { magicLink: {} } : {}),
// PowerSync needs JWT-with-audience + JWKS — Better-Auth's `jwt`
// plugin auto-exposes `/api/auth/.well-known/jwks` once enabled.
...(features.powerSync.enabled ? { jwtPlugin: { audience: "powersync" } } : {}),
Expand Down
42 changes: 27 additions & 15 deletions src/core/auth/better-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,12 @@ export interface BuildBetterAuthInput {
};
/**
* Switch on the magic-link plugin (Better-Auth 1.6 plugin slot 6/9
* per the PRD). Caller supplies a `sendMagicLink` closure so the
* email is delivered through the project's `EmailService`. Defaults
* are otherwise Better-Auth's: 5-minute link expiry, single-use.
* per the PRD). When `emailHooks` is wired the link is delivered
* through the shared email-hook runner (templated `magic-link` mail);
* a caller may still supply an explicit `sendMagicLink` closure to
* override that. Defaults otherwise: 5-minute link expiry, single-use.
*/
magicLink?: { sendMagicLink: MagicLinkSender };
magicLink?: { sendMagicLink?: MagicLinkSender };
/**
* Switch on the admin plugin (Better-Auth 1.6 plugin slot 4/9).
* Provides impersonation + ban + role assignment endpoints under
Expand Down Expand Up @@ -352,7 +353,7 @@ export function buildBetterAuth(input: BuildBetterAuthInput): ReturnType<typeof
}
}

if (input.magicLink && typeof input.magicLink.sendMagicLink !== "function") {
if (input.magicLink?.sendMagicLink && typeof input.magicLink.sendMagicLink !== "function") {
throw new Error("Better-Auth magicLink.sendMagicLink must be a function");
}
if (input.adminPlugin?.adminRoles && input.adminPlugin.adminRoles.length === 0) {
Expand All @@ -372,16 +373,8 @@ export function buildBetterAuth(input: BuildBetterAuthInput): ReturnType<typeof
const rpID = input.passkey.rpID ?? new URL(input.baseUrl).hostname;
plugins.push(passkey({ rpName: input.passkey.rpName, rpID, origin: input.baseUrl }));
}
if (input.magicLink) {
const send = input.magicLink.sendMagicLink;
plugins.push(
magicLink({
sendMagicLink: async ({ email, token, url }) => {
await send({ email, token, url });
},
}),
);
}
// magicLink is registered after `hookRunner` is built (below) so the
// send closure can route templated mail through the shared runner.
if (input.adminPlugin) {
plugins.push(
admin({
Expand Down Expand Up @@ -429,6 +422,25 @@ export function buildBetterAuth(input: BuildBetterAuthInput): ReturnType<typeof
})
: undefined;

// Magic-link mail rides the shared hook runner (templated `magic-link`
// email) when emailHooks are wired; an explicit caller `sendMagicLink`
// still wins. Registered here (after `hookRunner`) so the closure can
// reference it.
if (input.magicLink) {
const explicitSend = input.magicLink.sendMagicLink;
plugins.push(
magicLink({
sendMagicLink: async ({ email, token, url }) => {
if (explicitSend) {
await explicitSend({ email, token, url });
} else if (hookRunner) {
await hookRunner.sendMagicLink({ user: { id: "", email }, url, token });
}
},
}),
);
}

const passwordPolicyInput = input.passwordPolicy;
// Better-Auth's `password.hash` is the canonical place to gate the
// policy because it runs on signup AND change-password before the
Expand Down
1 change: 1 addition & 0 deletions src/core/email/email-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
export const CORE_EMAIL_TEMPLATES = [
"email-verification",
"password-reset",
"magic-link",
"welcome",
"invitation",
"new-device",
Expand Down
46 changes: 46 additions & 0 deletions src/core/email/templates/magic-link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as React from "react";

import { Barebone } from "../layouts/Barebone.js";
import { CTA, Footer, Greeting, Paragraph } from "../blocks/index.js";
import type { BrandConfig } from "../brand.js";

/**
* Magic-link sign-in template.
*
* Sent when a user requests a passwordless sign-in link. Subject pulls
* the appName so it reads natural across multi-brand deployments —
* `Your Acme sign-in link` instead of a generic fallback.
*/
export interface MagicLinkVars {
recipientName: string;
appName: string;
magicLinkUrl: string;
}

export const magicLinkMeta = {
name: "magic-link",
subject: (vars: MagicLinkVars): string => `Your ${vars.appName} sign-in link`,
};

export interface MagicLinkProps extends MagicLinkVars {
brand?: BrandConfig;
}

export default function MagicLink(props: MagicLinkProps): React.ReactElement {
return (
<Barebone brand={props.brand} preheader={`Your ${props.appName} sign-in link`}>
<Greeting brand={props.brand}>Hello {props.recipientName},</Greeting>
<Paragraph brand={props.brand}>
Tap the button below to sign in to {props.appName}. This link works once and expires
shortly.
</Paragraph>
<CTA brand={props.brand} href={props.magicLinkUrl}>
Sign in
</CTA>
<Footer brand={props.brand}>
If you did not request this link, you can safely ignore this email — no one can sign in
without it.
</Footer>
</Barebone>
);
}
20 changes: 20 additions & 0 deletions tests/stories/better-auth-email-hook-runner.story.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ describe("Story · Better-Auth email hook runner", () => {
});
});

it("sendMagicLink() forwards to the magic-link template", async () => {
const sender = fakeSender();
const runner = createEmailHookRunner({ sender, appName });
await runner.sendMagicLink({
user,
url: "https://x/auth/magic?t=1",
token: "t1",
});
expect(sender.calls).toHaveLength(1);
expect(sender.calls[0]).toMatchObject({
template: "magic-link",
to: "alice@example.com",
vars: {
recipientName: "Alice",
appName: "Acme",
magicLinkUrl: "https://x/auth/magic?t=1",
},
});
});

it("sendWelcome() forwards to the welcome template", async () => {
const sender = fakeSender();
const runner = createEmailHookRunner({ sender, appName });
Expand Down
24 changes: 24 additions & 0 deletions tests/stories/better-auth-email-hooks.story.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ describe("Story · Better-Auth email hooks (planner)", () => {
});
});

it("maps the magic-link hook to the magic-link template", () => {
const out = buildEmailHookPayload({
kind: "magic-link",
user,
url: "https://app.example.com/auth/magic?token=xyz",
appName,
});
expect(out).toEqual({
template: "magic-link",
to: "a@b.io",
vars: {
recipientName: "Alice",
appName: "Acme",
magicLinkUrl: "https://app.example.com/auth/magic?token=xyz",
},
});
});

it("rejects an empty url for the magic-link hook", () => {
expect(() => buildEmailHookPayload({ kind: "magic-link", user, url: "", appName })).toThrow(
/url/i,
);
});

it("maps the post-verification welcome hook to the welcome template", () => {
const out = buildEmailHookPayload({ kind: "welcome", user, appName });
expect(out).toEqual({
Expand Down
5 changes: 3 additions & 2 deletions tests/stories/email-builder.story.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,13 @@ describe("Story · Email-Builder", () => {
});

it("CORE_EMAIL_TEMPLATES catalogues every shipped core template", () => {
// The 5 templates that ship under src/core/email/templates/ — must
// match the on-disk inventory so the dev-portal "Core (default)"
// The transactional templates that ship under src/core/email/templates/
// — must match the on-disk inventory so the dev-portal "Core (default)"
// badge can be derived from this list alone.
expect([...CORE_EMAIL_TEMPLATES].sort()).toEqual([
"email-verification",
"invitation",
"magic-link",
"new-device",
"password-reset",
"welcome",
Expand Down
15 changes: 15 additions & 0 deletions tests/stories/email-templates-react.story.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ describe("Story · React-Email Templates", () => {
expect(out.text).toContain("https://app.example.test/verify?token=preview");
});

it("magic-link template renders the recipient + sign-in URL + appName in the subject", async () => {
const renderer = new ReactEmailTemplateRenderer({ brand: defaultBrandConfig() });
const out = await renderer.render("magic-link", "en", {
recipientName: "Pascal",
appName: "Acme",
magicLinkUrl: "https://app.example.test/auth/magic?token=preview",
});
// Subject pulls the appName so it reads natural per-brand.
expect(out.subject).toContain("Acme");
expect(out.subject).toMatch(/sign-in/i);
expect(out.html).toContain("Pascal");
expect(out.html).toContain("https://app.example.test/auth/magic?token=preview");
expect(out.text).toContain("https://app.example.test/auth/magic?token=preview");
});

it("welcome template renders the recipient + appName", async () => {
const renderer = new ReactEmailTemplateRenderer({ brand: defaultBrandConfig() });
const out = await renderer.render("welcome", "en", {
Expand Down
Loading