diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index d85bc3c2..92e67649 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -8,6 +8,7 @@ import { import { autumn } from "autumn-js/better-auth"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { APIError } from "better-auth/api"; import { admin, organization } from "better-auth/plugins"; import { eq } from "drizzle-orm"; import { favoriteProjectToSeed, pickActiveOrgId as pickActiveOrgIdRule } from "./active-context.js"; @@ -20,6 +21,7 @@ import { verificationEmailBody, } from "./email.js"; import { readSignupAttributionFromCookieHeader } from "./signup-click-ids.js"; +import { blockedSignupEmailDomains, isSignupEmailBlocked } from "./signup-email-domains.js"; import { enqueueUserCreated } from "./user-created-publisher.js"; import { configuredWebOrigins } from "./web-origins.js"; @@ -68,6 +70,8 @@ function sharedCookieDomain(): string | undefined { const COOKIE_DOMAIN = sharedCookieDomain(); +const BLOCKED_SIGNUP_EMAIL_DOMAINS = blockedSignupEmailDomains(); + // Autumn billing plugin. Maps each org (a Better Auth "organization") to an // Autumn customer, auto-created on first sign-in, so billing is per-org. Only // enabled when AUTUMN_SECRET_KEY is present, so local dev / worktrees without @@ -273,6 +277,17 @@ export const auth = betterAuth({ }, user: { create: { + // Runs before every new users row — email, social, and admin-created + // alike — so a blocked domain can't slip in through an OAuth provider. + before: async (user) => { + if (isSignupEmailBlocked(user.email, BLOCKED_SIGNUP_EMAIL_DOMAINS)) { + console.warn("[auth] rejected sign-up from blocked email domain"); + throw new APIError("FORBIDDEN", { + message: "Sign-ups from this email domain are not allowed.", + }); + } + return { data: user }; + }, after: async (user, context) => { // Fires on every new users row — email and social signups alike, and // users who never finish onboarding — so the signup count is captured diff --git a/apps/api/src/signup-email-domains.test.ts b/apps/api/src/signup-email-domains.test.ts new file mode 100644 index 00000000..4f1cd114 --- /dev/null +++ b/apps/api/src/signup-email-domains.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { blockedSignupEmailDomains, isSignupEmailBlocked } from "./signup-email-domains.js"; + +test("no domains are blocked unless the deployment configures some", () => { + assert.deepEqual(blockedSignupEmailDomains({}), []); + assert.deepEqual(blockedSignupEmailDomains({ SIGNUP_BLOCKED_EMAIL_DOMAINS: " , " }), []); + assert.equal(isSignupEmailBlocked("someone@example.com", []), false); +}); + +test("parses a comma-separated, case-insensitive domain list", () => { + assert.deepEqual( + blockedSignupEmailDomains({ SIGNUP_BLOCKED_EMAIL_DOMAINS: " Spam.example ,@junk.test,," }), + ["spam.example", "junk.test"], + ); +}); + +test("blocks addresses on a listed domain regardless of case", () => { + const blocked = ["spam.example"]; + assert.equal(isSignupEmailBlocked("bot@spam.example", blocked), true); + assert.equal(isSignupEmailBlocked("Bot@SPAM.Example", blocked), true); + assert.equal(isSignupEmailBlocked(" bot@spam.example ", blocked), true); +}); + +test("blocks subdomains of a listed domain", () => { + assert.equal(isSignupEmailBlocked("bot@mail.spam.example", ["spam.example"]), true); +}); + +test("does not block lookalike or unrelated domains", () => { + const blocked = ["spam.example"]; + assert.equal(isSignupEmailBlocked("person@notspam.example", blocked), false); + assert.equal(isSignupEmailBlocked("person@spam.example.org", blocked), false); + assert.equal(isSignupEmailBlocked("spam.example@legit.test", blocked), false); +}); + +test("uses the domain after the last @", () => { + assert.equal(isSignupEmailBlocked('"a@legit.test"@spam.example', ["spam.example"]), true); + assert.equal(isSignupEmailBlocked("no-at-sign", ["spam.example"]), false); +}); diff --git a/apps/api/src/signup-email-domains.ts b/apps/api/src/signup-email-domains.ts new file mode 100644 index 00000000..8ec84c4a --- /dev/null +++ b/apps/api/src/signup-email-domains.ts @@ -0,0 +1,22 @@ +type SignupEmailDomainEnv = { + SIGNUP_BLOCKED_EMAIL_DOMAINS?: string; +}; + +// Comma-separated list of email domains that may not create accounts (e.g. +// a domain sending spam sign-ups). Subdomains of a listed domain are blocked +// too. Unset = nothing blocked. +export function blockedSignupEmailDomains(env: SignupEmailDomainEnv = process.env): string[] { + return (env.SIGNUP_BLOCKED_EMAIL_DOMAINS ?? "") + .split(",") + .map((value) => value.trim().replace(/^@/, "").toLowerCase()) + .filter((value) => value.length > 0); +} + +export function isSignupEmailBlocked(email: string, blockedDomains: readonly string[]): boolean { + if (blockedDomains.length === 0) return false; + const normalized = email.trim().toLowerCase(); + const at = normalized.lastIndexOf("@"); + if (at === -1) return false; + const domain = normalized.slice(at + 1); + return blockedDomains.some((blocked) => domain === blocked || domain.endsWith(`.${blocked}`)); +}