Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9f012dd
Intercepts send.otp and send.magic_link webhook events from Neon Auth
b-at-neu Jun 27, 2026
d89d1b0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
b-at-neu Jun 27, 2026
21d5bd6
#256 rename NEON_AUTH_WEBHOOK_SECRET to SKIP_WEBHOOK_VERIFICATION
b-at-neu Jun 27, 2026
1adb4e0
#256 fix readme env var name to SKIP_WEBHOOK_VERIFICATION
b-at-neu Jun 27, 2026
811585e
#256 fix webhook signature verification to match neon's detached jws …
b-at-neu Jun 27, 2026
9a095ce
#256 use jose for webhook signature verification
b-at-neu Jun 27, 2026
bb45e6a
#256 add debug logging to webhook verification
b-at-neu Jun 27, 2026
05b4c5b
#256 add debug logging to webhook route
b-at-neu Jun 27, 2026
82ce40f
#256 log raw payload on schema failure
b-at-neu Jun 27, 2026
6f2863f
#256 fix webhook payload schema to match neon format
b-at-neu Jun 27, 2026
e06d51c
#256 add aplio sender name and logo png
b-at-neu Jun 27, 2026
72ea3c4
#256 redesign email templates with logo and red accents
b-at-neu Jun 28, 2026
5825cd6
#256 fix corrupted logo base64 in email template
b-at-neu Jun 28, 2026
02865d8
#256 fix email template colored elements for major clients
b-at-neu Jun 28, 2026
1fa1776
#256 add jose as a direct dependency
b-at-neu Jun 28, 2026
8cff5df
#256 address review feedback — branded email redesign
b-at-neu Jun 28, 2026
82914a0
#256 inline svg logo in email template as base64 data uri
b-at-neu Jun 29, 2026
2d92e6b
#256 serve logo from hosted url instead of base64 data uri
b-at-neu Jun 29, 2026
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ DIRECT_URL=postgresql://user:password@host/dbname?sslmode=verify-full

NEON_AUTH_BASE_URL=
NEON_AUTH_COOKIE_SECRET=

# Resend transactional email (Neon Auth webhook)
# Set SKIP_WEBHOOK_VERIFICATION=true to bypass Ed25519 signature verification in local dev.
RESEND_API_KEY=
RESEND_FROM_EMAIL=
SKIP_WEBHOOK_VERIFICATION=
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,15 @@ cp .env.example .env.local

Open `.env.local` and fill in the four required variables:

| Variable | Description |
| ------------------------- | -------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string (pooled). Local Docker: `postgresql://admin:admin@localhost:5432/aplio` |
| `DIRECT_URL` | Direct (non-pooled) connection string. Local Docker: same as `DATABASE_URL` |
| `NEON_AUTH_BASE_URL` | Stack Auth base URL from your Neon Auth project |
| `NEON_AUTH_COOKIE_SECRET` | Stack Auth cookie secret |
| Variable | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Postgres connection string (pooled). Local Docker: `postgresql://admin:admin@localhost:5432/aplio` |
| `DIRECT_URL` | Direct (non-pooled) connection string. Local Docker: same as `DATABASE_URL` |
| `NEON_AUTH_BASE_URL` | Stack Auth base URL from your Neon Auth project |
| `NEON_AUTH_COOKIE_SECRET` | Stack Auth cookie secret |
| `RESEND_API_KEY` | Resend API key for transactional email delivery |
| `RESEND_FROM_EMAIL` | Verified sender address in Resend (e.g. `noreply@yourdomain.com`) |
| `SKIP_WEBHOOK_VERIFICATION` | Set to `true` to bypass Ed25519 webhook signature verification in local development. Must not be set in production. |

> **Note:** Prisma CLI commands (`prisma:migrate`, `prisma:seed`) read from `.env`; Next.js reads `.env.local`. Both files are gitignored. For local development you can keep the same values in both.

Expand Down
124 changes: 124 additions & 0 deletions app/api/auth/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { z } from 'zod';

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R12-N1 ⚪ Nit — The plan and PR description both list lib/types.ts as a change adding NeonAuthWebhookOtpEvent, NeonAuthWebhookMagicLinkEvent, and NeonAuthWebhookEvent discriminated union types, but lib/types.ts is not in the diff and its file size is identical to dev. The route works correctly with its local zod schemas — no functional gap — but the PR description's Changes section inaccurately describes what was shipped. Either add the types or update the description.


import { sendEmail } from '@/lib/email/resend';
import { magicLinkEmail, otpEmail } from '@/lib/email/templates';
import { verifyWebhookSignature } from '@/lib/email/verify-webhook';

// Neon Auth webhook route — intercepts send.otp and send.magic_link events and
// delivers branded transactional emails through Resend instead of Neon's default
// sender (noreply@stackframe.co).
//
// This is the one permitted API route exception to the no-API-routes rule: it is
// auth infrastructure that must be a publicly reachable HTTP endpoint. It is NOT
// a Server Action, so the §4 { error }/throw action model does not apply — the
// contract here is plain HTTP status codes: 200 handled, 400 bad request, 500
// unexpected failure (so Neon can retry).
//
// Static segment "webhook" takes precedence over the [...path] catch-all beside it,
// so existing auth routes (login, callback, etc.) are unaffected.

// Neon uses event_type (not type) as the discriminator, and nests the recipient
// under user.email and event-specific fields under event_data.
const userSchema = z.object({ email: z.string().email().optional() });

const otpEventSchema = z.object({
event_type: z.literal('send.otp'),
user: userSchema,
event_data: z.object({
otp_code: z.string(),
expires_at: z.string().optional(),
}),
});

const magicLinkEventSchema = z.object({
event_type: z.literal('send.magic_link'),
user: userSchema,
event_data: z.object({
link_url: z.string().url(),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R11-L1 🟡 Low — z.string().url() accepts any scheme (including javascript:, data:). Neon always sends https:// magic-link URLs, and EdDSA signature verification sharply limits the attack surface, but defence-in-depth would add a scheme constraint: .refine(u => u.startsWith('https://'), 'link_url must be https'). Non-blocking at cycle 11.

expires_at: z.string().optional(),
}),
});

const webhookEventSchema = z.discriminatedUnion('event_type', [
otpEventSchema,
magicLinkEventSchema,
]);

const badRequest = () => new Response(null, { status: 400 });

function minutesUntil(isoString: string | undefined): number | undefined {
if (!isoString) return undefined;
const mins = Math.round(
(new Date(isoString).getTime() - Date.now()) / 60_000,
);
return mins > 0 ? mins : undefined;
}

export async function POST(req: Request): Promise<Response> {
// Read the raw body once — needed both for signature verification and JSON parsing.
const rawBody = await req.text();

// Verify the Neon Auth signature before doing anything else.
const signatureHeader = req.headers.get('X-Neon-Signature');
const kidHeader = req.headers.get('X-Neon-Signature-Kid');
const timestampHeader = req.headers.get('X-Neon-Timestamp');

const { valid } = await verifyWebhookSignature(
rawBody,
signatureHeader,
kidHeader,
timestampHeader,
);
if (!valid) return badRequest();

// Parse and validate the payload.
let parsed: unknown;
try {
parsed = JSON.parse(rawBody);
} catch {
return badRequest();
}

const result = webhookEventSchema.safeParse(parsed);
if (!result.success) return badRequest();

const event = result.data;
const email = event.user.email;
if (!email) return badRequest();

// Dispatch by event type and send the branded email.
try {
switch (event.event_type) {
case 'send.otp': {
const template = otpEmail({
code: event.event_data.otp_code,
expiresInMinutes: minutesUntil(event.event_data.expires_at),
});
await sendEmail({ to: email, ...template });
break;
}
case 'send.magic_link': {
const template = magicLinkEmail({
url: event.event_data.link_url,
expiresInMinutes: minutesUntil(event.event_data.expires_at),
});
await sendEmail({ to: email, ...template });
break;
}
default: {
// TypeScript exhaustiveness guard — unreachable if zod schema is complete.
const _exhaustive: never = event;
void _exhaustive;
return badRequest();
}
}
} catch (err) {
// Log server-side without leaking internals; return 500 so Neon retries.
// Note: a transient Resend failure + retry may cause a duplicate send for the
// same OTP/magic-link — acceptable for auth emails (they carry short-lived codes).
console.error('[webhook] Failed to send email:', err);
return new Response(null, { status: 500 });
}

return new Response(null, { status: 200 });
}
42 changes: 42 additions & 0 deletions lib/email/resend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'server-only';

import { Resend } from 'resend';

// Lazily constructed singleton — avoids construction at import time when the
// env var is absent (e.g. during build-time type-checking or non-email paths).
let _resend: Resend | null = null;

function getResend(): Resend {
if (!process.env.RESEND_API_KEY)
throw new Error('RESEND_API_KEY is not configured');
if (!_resend) _resend = new Resend(process.env.RESEND_API_KEY);
return _resend;
}

function getSenderAddress(): string {
if (!process.env.RESEND_FROM_EMAIL)
throw new Error('RESEND_FROM_EMAIL is not configured');
return `Aplio <${process.env.RESEND_FROM_EMAIL}>`;
}

export interface SendEmailParams {
to: string;
subject: string;
html: string;
text: string;
}

// Thin wrapper — throws on Resend errors so the webhook handler can catch and
// return 500 (Neon retries) rather than silently dropping the email.
export async function sendEmail({
to,
subject,
html,
text,
}: SendEmailParams): Promise<void> {
const resend = getResend();
const from = getSenderAddress();

const { error } = await resend.emails.send({ from, to, subject, html, text });
if (error) throw new Error(`Resend send failed: ${error.message}`);
}
170 changes: 170 additions & 0 deletions lib/email/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import 'server-only';

// Email templates for Neon Auth webhook events.
Comment thread
b-at-neu marked this conversation as resolved.
//
// Tailwind classes are ignored by email clients, so inline styles are used here.
// Logo is served from the hosted /logo-512.svg asset; VERCEL_URL is set automatically
// in Vercel deployments and falls back to localhost:3000 for local dev.
// Red accent #D41B2C matches the Aplio logo; zinc tokens match the app design system.

const LOGO_URL = `https://${process.env.VERCEL_URL ?? 'localhost:3000'}/logo-512.svg`;

// Prevent HTML injection in user-supplied values interpolated into email markup.
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

interface LayoutOptions {
title: string;
content: string;
}

function emailLayout({ title, content }: LayoutOptions): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${title}</title>
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:40px 16px;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;">
<!-- Card -->
<tr>
<td>
<table width="100%" cellpadding="0" cellspacing="0">
<!-- Branded red header -->
<tr>
<td bgcolor="#D41B2C" style="background-color:#D41B2C;padding:20px 28px;border-radius:10px 10px 0 0;">
<table cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align:middle;padding-right:10px;">
<img src="${LOGO_URL}" width="34" height="34" alt="" style="display:block;border-radius:6px;" />
</td>
<td style="vertical-align:middle;">
<span style="font-size:17px;font-weight:700;color:#ffffff;letter-spacing:-0.01em;">Aplio</span>
</td>
</tr>
</table>
</td>
</tr>
<!-- White card body -->
<tr>
<td bgcolor="#ffffff" style="background-color:#ffffff;padding:32px;border:1px solid #e4e4e7;border-top:none;border-radius:0 0 10px 10px;">
${content}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding-top:24px;font-size:12px;color:#71717a;text-align:center;">
You&#39;re receiving this because a sign-in was requested for your Aplio account.<br />
Didn&#39;t request this? You can safely ignore it.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}

export interface EmailTemplate {
subject: string;
html: string;
text: string;
}

export interface OtpEmailOptions {
code: string;
expiresInMinutes?: number;
}

export function otpEmail({
code,
expiresInMinutes,
}: OtpEmailOptions): EmailTemplate {
const safeCode = escapeHtml(code);
const expiryLine = expiresInMinutes
? `This code expires in ${expiresInMinutes} minute${expiresInMinutes === 1 ? '' : 's'}.`
: '';

const content = `
<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#09090b;">Your access code</h1>
<p style="margin:0 0 24px;font-size:14px;color:#71717a;">Enter this code to sign in to your Aplio account. It is single-use and will expire shortly.</p>
<table width="100%" cellpadding="0" cellspacing="0" style="border:2px solid #D41B2C;border-radius:6px;margin-bottom:24px;">
<tr>
<td style="padding:20px;text-align:center;">
<span style="font-size:36px;font-weight:700;letter-spacing:0.2em;color:#09090b;font-family:'Courier New',monospace;">${safeCode}</span>
</td>
</tr>
</table>
${expiryLine ? `<p style="margin:0;font-size:13px;color:#71717a;">${expiryLine}</p>` : ''}
`;

const textLines = [
'Your Aplio access code',
'',
`Code: ${code}`,
...(expiryLine ? [expiryLine] : []),
'',
'If you did not request this code, you can ignore this email.',
];

return {
subject: 'Your Aplio access code',
html: emailLayout({ title: 'Your Aplio access code', content }),
text: textLines.join('\n'),
};
}

export interface MagicLinkEmailOptions {
url: string;
expiresInMinutes?: number;
}

export function magicLinkEmail({
url,
expiresInMinutes,
}: MagicLinkEmailOptions): EmailTemplate {
const safeUrl = escapeHtml(url);
const expiryLine = expiresInMinutes
? `This link expires in ${expiresInMinutes} minute${expiresInMinutes === 1 ? '' : 's'}.`
: '';

const content = `
<h1 style="margin:0 0 8px;font-size:20px;font-weight:700;color:#09090b;">Your sign-in link</h1>
<p style="margin:0 0 24px;font-size:14px;color:#71717a;">Use the button below to sign in to your Aplio account &#8212; no password needed.</p>
<div style="text-align:center;margin-bottom:24px;">
<a href="${safeUrl}" style="display:inline-block;background-color:#D41B2C;color:#ffffff;text-decoration:none;padding:12px 32px;border-radius:6px;font-size:14px;font-weight:600;letter-spacing:0.01em;">Sign in to Aplio</a>
</div>
${expiryLine ? `<p style="margin:0 0 16px;font-size:13px;color:#71717a;">${expiryLine}</p>` : ''}
<p style="margin:0;font-size:12px;color:#71717a;">Or copy this link into your browser:<br /><span style="color:#09090b;word-break:break-all;">${safeUrl}</span></p>
`;

const textLines = [
'Sign in to Aplio',
'',
`Open this link to sign in: ${url}`,
...(expiryLine ? [expiryLine] : []),
'',
'If you did not request this link, you can ignore this email.',
];

return {
subject: 'Sign in to Aplio',
html: emailLayout({ title: 'Sign in to Aplio', content }),
text: textLines.join('\n'),
};
}
Loading
Loading