Skip to content

Commit b265e53

Browse files
mogitaBrendonovich
andauthored
feat: restricted signup by email domain (#863)
* feat: add domain restricted signup with existing user bypass * feat: use zod for validations * chore: update lock file * chore: update lock file * feat: adapt email and hostname validation to zod v3 * short-circuit signUp callback * formatting --------- Co-authored-by: Brendan Allan <[email protected]>
1 parent fba1be7 commit b265e53

File tree

5 files changed

+82
-1
lines changed

5 files changed

+82
-1
lines changed

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ CAP_AWS_ENDPOINT=${NEXT_PUBLIC_CAP_AWS_ENDPOINT}
6565
# Necessary for authentication, genearte by running `openssl rand -base64 32`
6666
NEXTAUTH_SECRET=
6767

68+
# Restrict signup to specific email domains (comma-separated)
69+
# If empty or not set, signup is open to all email addresses
70+
# Example: CAP_ALLOWED_SIGNUP_DOMAINS=company.com,partner.org
71+
# CAP_ALLOWED_SIGNUP_DOMAINS=
72+
6873
# Provide if you want to use Google authentication
6974
# GOOGLE_CLIENT_ID=
7075
# GOOGLE_CLIENT_SECRET=

packages/database/auth/auth-options.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { sendEmail } from "../emails/config";
1414
import { LoginLink } from "../emails/login-link";
1515
import { nanoId } from "../helpers";
1616
import { organizationMembers, organizations, users } from "../schema";
17+
import { isEmailAllowedForSignup } from "./domain-utils";
1718
import { DrizzleAdapter } from "./drizzle-adapter";
1819

1920
export const config = {
@@ -174,6 +175,29 @@ export const authOptions = (): NextAuthOptions => {
174175
},
175176
},
176177
callbacks: {
178+
async signIn({ user }) {
179+
const allowedDomains = serverEnv().CAP_ALLOWED_SIGNUP_DOMAINS;
180+
if (!allowedDomains) return true;
181+
182+
if (user.email) {
183+
const [existingUser] = await db()
184+
.select()
185+
.from(users)
186+
.where(eq(users.email, user.email))
187+
.limit(1);
188+
189+
// Only apply domain restrictions for new users, existing ones can always sign in
190+
if (
191+
!existingUser &&
192+
!isEmailAllowedForSignup(user.email, allowedDomains)
193+
) {
194+
console.warn(`Signup blocked for email domain: ${user.email}`);
195+
return false;
196+
}
197+
}
198+
199+
return true;
200+
},
177201
async session({ token, session }) {
178202
if (!session.user) return session;
179203

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { z } from "zod";
2+
3+
export function isEmailAllowedForSignup(
4+
email: string,
5+
allowedDomainsConfig?: string,
6+
): boolean {
7+
// If no domain restrictions are configured, allow all signups
8+
if (!allowedDomainsConfig || allowedDomainsConfig.trim() === "") {
9+
return true;
10+
}
11+
12+
const emailDomain = extractDomainFromEmail(email);
13+
if (!emailDomain) {
14+
return false;
15+
}
16+
17+
const allowedDomains = parseAllowedDomains(allowedDomainsConfig);
18+
return allowedDomains.includes(emailDomain.toLowerCase());
19+
}
20+
21+
function extractDomainFromEmail(email: string): string | null {
22+
// TODO: replace with zod v4's z.email()
23+
const emailValidation = z.string().email().safeParse(email);
24+
if (!emailValidation.success) {
25+
return null;
26+
}
27+
28+
// Extract domain from validated email
29+
const atIndex = email.lastIndexOf("@");
30+
return atIndex !== -1 ? email.substring(atIndex + 1) : null;
31+
}
32+
33+
function parseAllowedDomains(allowedDomainsConfig: string): string[] {
34+
return allowedDomainsConfig
35+
.split(",")
36+
.map((domain) => domain.trim().toLowerCase())
37+
.filter((domain) => domain.length > 0 && isValidDomain(domain));
38+
}
39+
40+
function isValidDomain(domain: string): boolean {
41+
// TODO: replace this polyfill with zod v4's z.hostname()
42+
const hostnameRegex =
43+
/^(?=.{1,253}$)(^((?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,63}$|localhost)$/;
44+
return z
45+
.string()
46+
.refine((val) => hostnameRegex.test(val), {
47+
message: "Invalid hostname",
48+
})
49+
.safeParse(domain).success;
50+
}

packages/env/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function createServerEnv() {
4545
GROQ_API_KEY: z.string().optional(),
4646
INTERCOM_SECRET: z.string().optional(),
4747
CAP_VIDEOS_DEFAULT_PUBLIC: boolString(true),
48+
CAP_ALLOWED_SIGNUP_DOMAINS: z.string().optional(),
4849
VERCEL_ENV: z
4950
.union([
5051
z.literal("production"),

pnpm-lock.yaml

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)