diff --git a/.env.example b/.env.example index 3dd78566a..65abc19de 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,11 @@ CAP_AWS_ENDPOINT=${NEXT_PUBLIC_CAP_AWS_ENDPOINT} # Necessary for authentication, genearte by running `openssl rand -base64 32` NEXTAUTH_SECRET= +# Restrict signup to specific email domains (comma-separated) +# If empty or not set, signup is open to all email addresses +# Example: CAP_ALLOWED_SIGNUP_DOMAINS=company.com,partner.org +# CAP_ALLOWED_SIGNUP_DOMAINS= + # Provide if you want to use Google authentication # GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_SECRET= diff --git a/packages/database/auth/auth-options.tsx b/packages/database/auth/auth-options.tsx index 99b0c0497..2d7afe8ed 100644 --- a/packages/database/auth/auth-options.tsx +++ b/packages/database/auth/auth-options.tsx @@ -14,6 +14,7 @@ import { sendEmail } from "../emails/config"; import { LoginLink } from "../emails/login-link"; import { nanoId } from "../helpers"; import { organizationMembers, organizations, users } from "../schema"; +import { isEmailAllowedForSignup } from "./domain-utils"; import { DrizzleAdapter } from "./drizzle-adapter"; export const config = { @@ -174,6 +175,29 @@ export const authOptions = (): NextAuthOptions => { }, }, callbacks: { + async signIn({ user }) { + const allowedDomains = serverEnv().CAP_ALLOWED_SIGNUP_DOMAINS; + if (!allowedDomains) return true; + + if (user.email) { + const [existingUser] = await db() + .select() + .from(users) + .where(eq(users.email, user.email)) + .limit(1); + + // Only apply domain restrictions for new users, existing ones can always sign in + if ( + !existingUser && + !isEmailAllowedForSignup(user.email, allowedDomains) + ) { + console.warn(`Signup blocked for email domain: ${user.email}`); + return false; + } + } + + return true; + }, async session({ token, session }) { if (!session.user) return session; diff --git a/packages/database/auth/domain-utils.ts b/packages/database/auth/domain-utils.ts new file mode 100644 index 000000000..9f6438301 --- /dev/null +++ b/packages/database/auth/domain-utils.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +export function isEmailAllowedForSignup( + email: string, + allowedDomainsConfig?: string, +): boolean { + // If no domain restrictions are configured, allow all signups + if (!allowedDomainsConfig || allowedDomainsConfig.trim() === "") { + return true; + } + + const emailDomain = extractDomainFromEmail(email); + if (!emailDomain) { + return false; + } + + const allowedDomains = parseAllowedDomains(allowedDomainsConfig); + return allowedDomains.includes(emailDomain.toLowerCase()); +} + +function extractDomainFromEmail(email: string): string | null { + // TODO: replace with zod v4's z.email() + const emailValidation = z.string().email().safeParse(email); + if (!emailValidation.success) { + return null; + } + + // Extract domain from validated email + const atIndex = email.lastIndexOf("@"); + return atIndex !== -1 ? email.substring(atIndex + 1) : null; +} + +function parseAllowedDomains(allowedDomainsConfig: string): string[] { + return allowedDomainsConfig + .split(",") + .map((domain) => domain.trim().toLowerCase()) + .filter((domain) => domain.length > 0 && isValidDomain(domain)); +} + +function isValidDomain(domain: string): boolean { + // TODO: replace this polyfill with zod v4's z.hostname() + const hostnameRegex = + /^(?=.{1,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(? hostnameRegex.test(val), { + message: "Invalid hostname", + }) + .safeParse(domain).success; +} diff --git a/packages/env/server.ts b/packages/env/server.ts index 0868f4362..678825bc5 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -45,6 +45,7 @@ function createServerEnv() { GROQ_API_KEY: z.string().optional(), INTERCOM_SECRET: z.string().optional(), CAP_VIDEOS_DEFAULT_PUBLIC: boolString(true), + CAP_ALLOWED_SIGNUP_DOMAINS: z.string().optional(), VERCEL_ENV: z .union([ z.literal("production"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 880d44daf..d886fd36b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12567,11 +12567,12 @@ packages: superagent@8.1.2: resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} engines: {node: '>=6.4.0 <13 || >=14'} - deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@6.3.4: resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} engines: {node: '>=6.4.0'} + deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net supports-color@10.0.0: resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==}