Skip to content
Open
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
15 changes: 15 additions & 0 deletions apps/api/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/signup-email-domains.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
22 changes: 22 additions & 0 deletions apps/api/src/signup-email-domains.ts
Original file line number Diff line number Diff line change
@@ -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}`));
}
Loading