Skip to content

Commit 12986d8

Browse files
feat: Disallow disposable emails
1 parent 7b319b2 commit 12986d8

File tree

6 files changed

+124
-3
lines changed

6 files changed

+124
-3
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Have a look at the [project plan](./cursor-docs/project-plan.md) to get an overv
1515
- 📧 Email/Password Sign In
1616
- 📝 Email/Password Sign Up
1717
- 🔑 WebAuthn/Passkey Authentication
18+
- 🌐 Google OAuth/SSO Integration
1819
- 🔄 Forgot Password Flow
1920
- 🔒 Change Password
2021
- ✉️ Email Verification
@@ -23,6 +24,7 @@ Have a look at the [project plan](./cursor-docs/project-plan.md) to get an overv
2324
- ⚡ Rate Limiting for Auth Endpoints
2425
- 🛡️ Protected Routes and Layouts
2526
- 📋 Session Listing and Management
27+
- 🔒 Anti-Disposable Email Protection
2628
- 💾 Database with Drizzle and Cloudflare D1
2729
- 🏗️ Type-safe Database Operations
2830
- 🔄 Automatic Migration Generation

src/app/(auth)/sign-up/passkey-sign-up.actions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { userTable } from "@/db/schema";
88
import { eq } from "drizzle-orm";
99
import { createId } from "@paralleldrive/cuid2";
1010
import { cookies, headers } from "next/headers";
11-
import { createSession, generateSessionToken, setSessionTokenCookie } from "@/utils/auth";
11+
import { createSession, generateSessionToken, setSessionTokenCookie, canSignUp } from "@/utils/auth";
1212
import type { RegistrationResponseJSON, PublicKeyCredentialCreationOptionsJSON } from "@simplewebauthn/typescript-types";
1313
import { withRateLimit, RATE_LIMITS } from "@/utils/with-rate-limit";
1414
import { getIP } from "@/utils/getIP";
@@ -28,6 +28,10 @@ export const startPasskeyRegistrationAction = createServerAction()
2828
return withRateLimit(
2929
async () => {
3030
const db = getDB();
31+
32+
// Check if email is disposable
33+
await canSignUp({ email: input.email });
34+
3135
const existingUser = await db.query.userTable.findFirst({
3236
where: eq(userTable.email, input.email),
3337
});

src/app/(auth)/sign-up/sign-up.actions.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getDB } from "@/db"
55
import { userTable } from "@/db/schema"
66
import { signUpSchema } from "@/schemas/signup.schema";
77
import { hashPassword } from "@/utils/passwordHasher";
8-
import { createSession, generateSessionToken, setSessionTokenCookie } from "@/utils/auth";
8+
import { createSession, generateSessionToken, setSessionTokenCookie, canSignUp } from "@/utils/auth";
99
import { eq } from "drizzle-orm";
1010
import { createId } from "@paralleldrive/cuid2";
1111
import { getCloudflareContext } from "@opennextjs/cloudflare";
@@ -25,6 +25,9 @@ export const signUpAction = createServerAction()
2525

2626
// TODO Implement a captcha
2727

28+
// Check if email is disposable
29+
await canSignUp({ email: input.email });
30+
2831
// Check if email is already taken
2932
const existingUser = await db.query.userTable.findFirst({
3033
where: eq(userTable.email, input.email),

src/app/(auth)/sso/google/callback/google-callback.action.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { decodeIdToken } from "arctic";
1010
import { getDB } from "@/db";
1111
import { eq } from "drizzle-orm";
1212
import { userTable } from "@/db/schema";
13-
import { createAndStoreSession } from "@/utils/auth";
13+
import { createAndStoreSession, canSignUp } from "@/utils/auth";
1414
import { isGoogleSSOEnabled } from "@/flags";
1515
import { getIP } from "@/utils/getIP";
1616

@@ -96,6 +96,9 @@ export const googleSSOCallbackAction = createServerAction()
9696
const avatarUrl = claims.picture;
9797
const email = claims.email;
9898

99+
// Check if email is disposable
100+
await canSignUp({ email });
101+
99102
const db = getDB();
100103

101104
try {

src/utils/auth.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,106 @@ export async function requireVerifiedEmail() {
218218

219219
return session;
220220
}
221+
222+
interface DisposableEmailResponse {
223+
disposable: string;
224+
}
225+
226+
interface MailcheckResponse {
227+
status: number;
228+
email: string;
229+
domain: string;
230+
mx: boolean;
231+
disposable: boolean;
232+
public_domain: boolean;
233+
relay_domain: boolean;
234+
alias: boolean;
235+
role_account: boolean;
236+
did_you_mean: string | null;
237+
}
238+
239+
type ValidatorResult = {
240+
success: boolean;
241+
isDisposable: boolean;
242+
};
243+
244+
/**
245+
* Checks if an email is disposable using debounce.io
246+
*/
247+
async function checkWithDebounce(email: string): Promise<ValidatorResult> {
248+
try {
249+
const response = await fetch(`https://disposable.debounce.io/?email=${encodeURIComponent(email)}`);
250+
251+
if (!response.ok) {
252+
console.error("Debounce.io API error:", response.status);
253+
return { success: false, isDisposable: false };
254+
}
255+
256+
const data = await response.json() as DisposableEmailResponse;
257+
258+
return { success: true, isDisposable: data.disposable === "true" };
259+
} catch (error) {
260+
console.error("Failed to check disposable email with debounce.io:", error);
261+
return { success: false, isDisposable: false };
262+
}
263+
}
264+
265+
/**
266+
* Checks if an email is disposable using mailcheck.ai
267+
*/
268+
async function checkWithMailcheck(email: string): Promise<ValidatorResult> {
269+
try {
270+
const response = await fetch(`https://api.mailcheck.ai/email/${encodeURIComponent(email)}`);
271+
272+
if (!response.ok) {
273+
console.error("Mailcheck.ai API error:", response.status);
274+
return { success: false, isDisposable: false };
275+
}
276+
277+
const data = await response.json() as MailcheckResponse;
278+
return { success: true, isDisposable: data.disposable };
279+
} catch (error) {
280+
console.error("Failed to check disposable email with mailcheck.ai:", error);
281+
return { success: false, isDisposable: false };
282+
}
283+
}
284+
285+
286+
/**
287+
* Checks if an email is allowed for sign up by verifying it's not a disposable email
288+
* Uses multiple services in sequence for redundancy.
289+
*
290+
* @throws {ZSAError} If email is disposable or if all services fail
291+
*/
292+
export async function canSignUp({ email }: { email: string }): Promise<void> {
293+
const validators = [
294+
checkWithDebounce,
295+
checkWithMailcheck,
296+
];
297+
298+
for (const validator of validators) {
299+
const result = await validator(email);
300+
301+
// If the validator failed (network error, rate limit, etc), try the next one
302+
if (!result.success) {
303+
continue;
304+
}
305+
306+
// If we got a successful response and it's disposable, reject the signup
307+
if (result.isDisposable) {
308+
throw new ZSAError(
309+
"PRECONDITION_FAILED",
310+
"Disposable email addresses are not allowed"
311+
);
312+
}
313+
314+
// If we got a successful response and it's not disposable, allow the signup
315+
return;
316+
}
317+
318+
// If all validators failed, we can't verify the email
319+
throw new ZSAError(
320+
"PRECONDITION_FAILED",
321+
"Unable to verify email address at this time. Please try again later."
322+
);
323+
}

src/utils/with-rate-limit.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import "server-only";
22
import { checkRateLimit } from "./rate-limit";
33
import { getIP } from "./getIP";
44
import ms from "ms";
5+
import isProd from "./isProd";
56

67
interface RateLimitConfig {
78
identifier: string;
@@ -13,6 +14,11 @@ export async function withRateLimit<T>(
1314
action: () => Promise<T>,
1415
config: RateLimitConfig
1516
): Promise<T> {
17+
18+
if (!isProd) {
19+
return action();
20+
}
21+
1622
const ip = await getIP();
1723

1824
const rateLimitResult = await checkRateLimit({

0 commit comments

Comments
 (0)