Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
24 changes: 24 additions & 0 deletions packages/database/auth/auth-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;

Expand Down
50 changes: 50 additions & 0 deletions packages/database/auth/domain-utils.ts
Original file line number Diff line number Diff line change
@@ -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}(?<!-)\.)+[a-zA-Z]{2,63}$|localhost)$/;
return z
.string()
.refine((val) => hostnameRegex.test(val), {
message: "Invalid hostname",
})
.safeParse(domain).success;
}
1 change: 1 addition & 0 deletions packages/env/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading