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
24 changes: 22 additions & 2 deletions shared/authentication/src/auth.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@ import { account, db, invitation, jwks, member, organization, session, user, ver
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { createAuthMiddleware } from 'better-auth/api';
import { admin, anonymous, bearer, jwt, organization as organizationPlugin, username } from 'better-auth/plugins';
import {
admin,
anonymous,
bearer,
emailOTP,
jwt,
organization as organizationPlugin,
username,
} from 'better-auth/plugins';
import { eq, sql } from 'drizzle-orm';
import { WXYCRoles } from './auth.roles';
import { sendEmail, sendVerificationEmailMessage } from './email';
import { sendEmail, sendOTPEmail, sendResetPasswordEmail, sendVerificationEmailMessage } from './email';
import { rewriteUrlForFrontend } from './url-rewrite';

const buildResetUrl = (url: string, redirectTo?: string) => {
Expand Down Expand Up @@ -260,6 +268,18 @@ export const auth = betterAuth({
},
},
}),
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
void sendOTPEmail({ to: email, otp, type }).catch((error) => {
console.error('Error sending OTP email:', error);
});
},
otpLength: 6,
expiresIn: 300,
disableSignUp: true,
allowedAttempts: 5,
storeOTP: process.env.NODE_ENV === 'production' ? 'hashed' : 'plain',
}),
],

hooks: {
Expand Down
94 changes: 94 additions & 0 deletions shared/authentication/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,97 @@ export const sendVerificationEmailMessage = async ({ to, verificationUrl }: { to

export const sendAccountSetupEmail = async ({ to, setupUrl }: { to: string; setupUrl: string }) =>
sendEmail({ type: 'accountSetup', to, url: setupUrl });

type OTPEmailInput = {
to: string;
otp: string;
type: 'sign-in' | 'email-verification' | 'forget-password' | 'change-email';
};

type OTPEmailTemplateInput = {
title: string;
intro: string;
otp: string;
footer?: string;
};

export const buildOTPEmailHtml = ({ title, intro, otp, footer }: OTPEmailTemplateInput) =>
`
<div style="background-color:#0b0a10;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#fce7f3;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:600px;margin:0 auto;background:#14101a;border-radius:12px;overflow:hidden;">
<tr>
<td style="padding:24px 28px;border-bottom:1px solid #2a2033;">
<div style="min-height:48px;display:flex;align-items:center;justify-content:center;">
<img
src="https://wxyc.org/_next/static/media/logo.cecf836c.png"
alt="WXYC"
width="180"
style="display:block;border:0;outline:none;text-decoration:none;height:auto;"
/>
</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<h1 style="margin:0 0 12px;font-size:22px;line-height:1.3;color:#fdf2f8;">${title}</h1>
<p style="margin:0 0 18px;font-size:15px;line-height:1.6;color:#f9a8d4;">
${intro}
</p>
<div style="text-align:center;margin:24px 0;">
<span style="font-size:32px;font-family:monospace;letter-spacing:8px;background:#1a0b14;padding:12px 24px;border-radius:8px;color:#ec4899;display:inline-block;">${otp}</span>
</div>
</td>
</tr>
<tr>
<td style="padding:20px 28px;background:#0d0a12;color:#f9a8d4;font-size:12px;line-height:1.5;">
${footer || 'If you did not request this email, you can safely ignore it.'}
</td>
</tr>
</table>
</div>
`.trim();

export const sendOTPEmail = async ({ to, otp, type }: OTPEmailInput) => {
const from = process.env.SES_FROM_EMAIL;
if (!from) {
throw new Error('Missing AWS SES configuration: SES_FROM_EMAIL');
}

const subjectMap: Record<OTPEmailInput['type'], string> = {
'sign-in': 'Your WXYC login code',
'email-verification': 'Your WXYC verification code',
'forget-password': 'Your WXYC password reset code',
'change-email': 'Your WXYC email change code',
};

const introMap: Record<OTPEmailInput['type'], string> = {
'sign-in': 'Use this code to sign in to your account.',
'email-verification': 'Use this code to verify your email address.',
'forget-password': 'Use this code to reset your password.',
'change-email': 'Use this code to confirm your email change.',
};

const subject = subjectMap[type];
const textBody = `Your code is: ${otp}. It expires in 5 minutes.`;
const htmlBody = buildOTPEmailHtml({
title: subject,
intro: introMap[type],
otp,
footer: "This code expires in 5 minutes. If you didn't request this, you can safely ignore it.",
});

const command = new SendEmailCommand({
Source: from,
Destination: { ToAddresses: [to] },
Message: {
Subject: { Data: subject },
Body: {
Text: { Data: textBody },
Html: { Data: htmlBody },
},
},
});

const client = getSesClient();
await client.send(command);
};
130 changes: 130 additions & 0 deletions tests/unit/authentication/email-otp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';

// Mock AWS SES
const mockSend = jest.fn();
jest.mock('@aws-sdk/client-ses', () => ({
SESClient: jest.fn().mockImplementation(() => ({
send: mockSend,
})),
SendEmailCommand: jest.fn().mockImplementation((params) => params),
}));

// Set required env vars before importing the module
process.env.AWS_ACCESS_KEY_ID = 'test-key';
process.env.AWS_SECRET_ACCESS_KEY = 'test-secret';
process.env.AWS_REGION = 'us-east-1';
process.env.SES_FROM_EMAIL = 'noreply@wxyc.org';

// Import after mocks and env setup
import { sendOTPEmail, buildOTPEmailHtml } from '../../../shared/authentication/src/email';

describe('Email OTP', () => {
beforeEach(() => {
mockSend.mockReset();
mockSend.mockResolvedValue({});
});

describe('buildOTPEmailHtml', () => {
it('should render the OTP code in the email', () => {
const html = buildOTPEmailHtml({
title: 'Your WXYC login code',
intro: 'Use this code to sign in to your account.',
otp: '123456',
});

expect(html).toContain('123456');
expect(html).toContain('Your WXYC login code');
expect(html).toContain('Use this code to sign in to your account.');
});

it('should include WXYC branding', () => {
const html = buildOTPEmailHtml({
title: 'Test',
intro: 'Test',
otp: '000000',
});

expect(html).toContain('wxyc.org');
expect(html).toContain('alt="WXYC"');
});

it('should use monospace font for the OTP display', () => {
const html = buildOTPEmailHtml({
title: 'Test',
intro: 'Test',
otp: '654321',
});

expect(html).toContain('font-family:monospace');
expect(html).toContain('letter-spacing:8px');
});

it('should render custom footer when provided', () => {
const html = buildOTPEmailHtml({
title: 'Test',
intro: 'Test',
otp: '000000',
footer: 'Custom footer text',
});

expect(html).toContain('Custom footer text');
});

it('should render default footer when none provided', () => {
const html = buildOTPEmailHtml({
title: 'Test',
intro: 'Test',
otp: '000000',
});

expect(html).toContain('If you did not request this email');
});
});

describe('sendOTPEmail', () => {
it.each([
{
type: 'sign-in' as const,
expectedSubject: 'Your WXYC login code',
expectedIntro: 'Use this code to sign in to your account.',
},
{
type: 'email-verification' as const,
expectedSubject: 'Your WXYC verification code',
expectedIntro: 'Use this code to verify your email address.',
},
{
type: 'forget-password' as const,
expectedSubject: 'Your WXYC password reset code',
expectedIntro: 'Use this code to reset your password.',
},
])(
'should send $type OTP email with correct subject and content',
async ({ type, expectedSubject, expectedIntro }) => {
await sendOTPEmail({ to: 'dj@wxyc.org', otp: '123456', type });

expect(mockSend).toHaveBeenCalledTimes(1);

const command = mockSend.mock.calls[0][0] as any;
expect(command.Source).toBe('noreply@wxyc.org');
expect(command.Destination.ToAddresses).toEqual(['dj@wxyc.org']);
expect(command.Message.Subject.Data).toBe(expectedSubject);
expect(command.Message.Body.Text.Data).toContain('123456');
expect(command.Message.Body.Text.Data).toContain('expires in 5 minutes');
expect(command.Message.Body.Html.Data).toContain('123456');
expect(command.Message.Body.Html.Data).toContain(expectedIntro);
}
);

it('should throw if SES_FROM_EMAIL is not set', async () => {
const originalFrom = process.env.SES_FROM_EMAIL;
delete process.env.SES_FROM_EMAIL;

await expect(sendOTPEmail({ to: 'dj@wxyc.org', otp: '123456', type: 'sign-in' })).rejects.toThrow(
'SES_FROM_EMAIL'
);

process.env.SES_FROM_EMAIL = originalFrom;
});
});
});
Loading