From 66e357c9b3f1f597277b4a6d70ca08b985b6181d Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Sat, 14 Mar 2026 05:32:34 +0000 Subject: [PATCH] feat: add email domain restriction for signups (#2266) Co-authored-by: Lucas Smith --- .env.example | 2 + .../configuration/environment.mdx | 34 ++++- .../deployment/docker-compose.mdx | 6 +- .../docs/self-hosting/deployment/docker.mdx | 1 + .../docs/self-hosting/deployment/railway.mdx | 5 +- apps/remix/app/components/forms/signup.tsx | 119 +++++++++--------- .../app/components/general/claim-account.tsx | 5 +- .../app/routes/_unauthenticated+/signin.tsx | 23 +++- docker/README.md | 1 + docker/production/compose.yml | 1 + .../auth/server/lib/errors/error-codes.ts | 1 + .../lib/utils/handle-oauth-callback-url.ts | 21 ++++ packages/auth/server/routes/email-password.ts | 17 ++- packages/lib/constants/auth.ts | 37 ++++++ packages/tsconfig/process-env.d.ts | 1 + turbo.json | 1 + 16 files changed, 194 insertions(+), 81 deletions(-) diff --git a/.env.example b/.env.example index 2ba589d9d..69bd1961d 100644 --- a/.env.example +++ b/.env.example @@ -153,6 +153,8 @@ NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_FEATURE_BILLING_ENABLED= # OPTIONAL: Leave blank to allow users to signup through /signup page. NEXT_PUBLIC_DISABLE_SIGNUP= +# OPTIONAL: Comma-separated list of email domains allowed to sign up (e.g., example.com,acme.org). +NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS= # OPTIONAL: Set to true to use internal webapp url in browserless requests. NEXT_PUBLIC_USE_INTERNAL_URL_BROWSERLESS=false diff --git a/apps/docs/content/docs/self-hosting/configuration/environment.mdx b/apps/docs/content/docs/self-hosting/configuration/environment.mdx index 960008449..712551c14 100644 --- a/apps/docs/content/docs/self-hosting/configuration/environment.mdx +++ b/apps/docs/content/docs/self-hosting/configuration/environment.mdx @@ -224,11 +224,31 @@ For detailed certificate setup, see [Signing Certificate](/docs/self-hosting/con ## Feature Flags -| Variable | Description | Default | -| ------------------------------------- | ----------------------------------------------- | ------- | -| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration | `false` | -| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | | -| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` | +| Variable | Description | Default | +| ------------------------------------- | ----------------------------------------------------------------------------------- | ------- | +| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public user registration entirely | `false` | +| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of email domains allowed to sign up (e.g., `example.com,acme.org`) | | +| `NEXT_PUBLIC_POSTHOG_KEY` | PostHog API key for analytics and feature flags | | +| `NEXT_PUBLIC_FEATURE_BILLING_ENABLED` | Enable billing features | `false` | + +### Signup Restrictions + +You can control who is allowed to create accounts on your instance using two environment variables: + +- **`NEXT_PUBLIC_DISABLE_SIGNUP`**: Set to `true` to block all new signups. Existing users can still sign in. This applies to both email/password and OAuth signups. +- **`NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`**: Restrict signups to specific email domains. When set, only users whose email address matches one of the listed domains can create an account. Leave empty to allow all domains. + +Both restrictions apply to email/password registration and OAuth (Google, Microsoft, OIDC). If a user attempts to sign up via OAuth with a disallowed domain, they are redirected to the sign-in page with an error. + +When both variables are set, `NEXT_PUBLIC_DISABLE_SIGNUP` takes precedence. Signups are blocked regardless of the domain list. + +```bash +# Allow signups only from specific domains +NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org" + +# Or disable signups entirely +NEXT_PUBLIC_DISABLE_SIGNUP="true" +``` --- @@ -328,6 +348,10 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS="noreply@example.com" # Signing (certificate must be configured) NEXT_PRIVATE_SIGNING_PASSPHRASE="your-certificate-password" + +# Signup restrictions (optional) +# NEXT_PUBLIC_DISABLE_SIGNUP="true" +# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS="example.com,acme.org" ``` --- diff --git a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx index 87f33610d..4f5cac284 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker-compose.mdx @@ -154,8 +154,9 @@ PORT=3000 # Signing certificate (see Signing Certificate section) NEXT_PRIVATE_SIGNING_PASSPHRASE=your-certificate-password -# Disable public signups +# Signup restrictions (optional) NEXT_PUBLIC_DISABLE_SIGNUP=false +# NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS=example.com,acme.org ``` Generate secure secrets using: `openssl rand -base64 32` @@ -251,7 +252,8 @@ Navigate to the signup page and create your account. Verify your email address All accounts created through signup are regular user accounts. Admin access must be granted directly in the database. Once your accounts are set up, consider disabling public signups by - setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`. + setting `NEXT_PUBLIC_DISABLE_SIGNUP=true`, or restrict signups to specific email domains with + `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS`. ## Managing Services diff --git a/apps/docs/content/docs/self-hosting/deployment/docker.mdx b/apps/docs/content/docs/self-hosting/deployment/docker.mdx index 68bcc2abc..18c80ec31 100644 --- a/apps/docs/content/docs/self-hosting/deployment/docker.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/docker.mdx @@ -101,6 +101,7 @@ See [Email Configuration](/docs/self-hosting/configuration/email) for other tran | `NEXT_PRIVATE_SIGNING_LOCAL_FILE_CONTENTS` | Base64-encoded `.p12` certificate (alternative to file path) | - | | `NEXT_PUBLIC_UPLOAD_TRANSPORT` | Document storage: `database` or `s3` | `database` | | `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` | +| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | For the complete list, see [Environment Variables](/docs/self-hosting/configuration/environment). diff --git a/apps/docs/content/docs/self-hosting/deployment/railway.mdx b/apps/docs/content/docs/self-hosting/deployment/railway.mdx index db775b85a..f1e3151a9 100644 --- a/apps/docs/content/docs/self-hosting/deployment/railway.mdx +++ b/apps/docs/content/docs/self-hosting/deployment/railway.mdx @@ -153,8 +153,9 @@ NEXT_PRIVATE_SMTP_FROM_ADDRESS=noreply@yourdomain.com | Variable | Description | Default | | --------------------------------- | ---------------------------------- | ------- | | `PORT` | Application port | `3000` | -| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` | -| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - | +| `NEXT_PUBLIC_DISABLE_SIGNUP` | Disable public signups | `false` | +| `NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS` | Comma-separated list of allowed signup email domains | | +| `NEXT_PRIVATE_SIGNING_PASSPHRASE` | Passphrase for signing certificate | - | | `DOCUMENSO_DISABLE_TELEMETRY` | Disable anonymous telemetry | `false` | diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 39cc4f0f3..368f7dcea 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -54,8 +54,8 @@ export const ZSignUpFormSchema = z }, ); -export const signupErrorMessages: Record = { - SIGNUP_DISABLED: msg`Signups are disabled.`, +export const SIGNUP_ERROR_MESSAGES: Record = { + SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`, [AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`, [AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`, }; @@ -130,7 +130,8 @@ export const SignUpForm = ({ } catch (err) { const error = AppError.parseError(err); - const errorMessage = signupErrorMessages[error.code] ?? signupErrorMessages.INVALID_REQUEST; + const errorMessage = + SIGNUP_ERROR_MESSAGES[error.code] ?? SIGNUP_ERROR_MESSAGES.INVALID_REQUEST; toast({ title: _(msg`An error occurred`), @@ -196,7 +197,7 @@ export const SignUpForm = ({ return (
-
+
-
+
-
+
User profiles are here!
@@ -223,13 +224,13 @@ export const SignUpForm = ({
-
+

Create a new account

-

+

Create your account and start using state-of-the-art document signing. Open and beautiful signing is within your grasp. @@ -323,70 +324,62 @@ export const SignUpForm = ({ /> {hasSocialAuthEnabled && ( - <> -

-
- - Or - -
-
- +
+
+ + Or + +
+
)} {isGoogleSSOEnabled && ( - <> - - + )} {isMicrosoftSSOEnabled && ( - <> - - + )} {isOIDCSSOEnabled && ( - <> - - + )} -

+

Already have an account?{' '} @@ -406,7 +399,7 @@ export const SignUpForm = ({ -

+

By proceeding, you agree to our{' '} { const hash = window.location.hash.slice(1); @@ -69,12 +78,18 @@ export default function SignIn({ loaderData }: Route.ComponentProps) { return (

-
+
+ {signupError && ( + + {_(signupError)} + + )} +

Sign in to your account

-

+

Welcome back, we are lucky to have you.


@@ -88,7 +103,7 @@ export default function SignIn({ loaderData }: Route.ComponentProps) { /> {!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && ( -

+

Don't have an account?{' '} { const user = await tx.user.create({ diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index da8c6e9d2..8e79f7347 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -6,6 +6,7 @@ import { HTTPException } from 'hono/http-exception'; import { DateTime } from 'luxon'; import { z } from 'zod'; +import { isEmailDomainAllowedForSignup } from '@documenso/lib/constants/auth'; import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email'; import { AppError } from '@documenso/lib/errors/app-error'; import { jobsClient } from '@documenso/lib/jobs/client'; @@ -122,7 +123,11 @@ export const emailPasswordRoute = new Hono() const is2faEnabled = isTwoFactorAuthenticationEnabled({ user }); if (is2faEnabled) { - const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user }); + const isValid = await validateTwoFactorAuthentication({ + backupCode, + totpCode, + user, + }); if (!isValid) { await prisma.userSecurityAuditLog.create({ @@ -178,8 +183,8 @@ export const emailPasswordRoute = new Hono() const requestMetadata = c.get('requestMetadata'); if (env('NEXT_PUBLIC_DISABLE_SIGNUP') === 'true') { - throw new AppError('SIGNUP_DISABLED', { - message: 'Signups are disabled.', + throw new AppError(AuthenticationErrorCode.SignupDisabled, { + statusCode: 400, }); } @@ -196,6 +201,12 @@ export const emailPasswordRoute = new Hono() res: signupLimited, }); } + + if (!isEmailDomainAllowedForSignup(email)) { + throw new AppError(AuthenticationErrorCode.SignupDisabled, { + statusCode: 400, + }); + } const user = await createUser({ name, email, password, signature }).catch((err) => { console.error(err); diff --git a/packages/lib/constants/auth.ts b/packages/lib/constants/auth.ts index 3d954057c..ee3c9de49 100644 --- a/packages/lib/constants/auth.ts +++ b/packages/lib/constants/auth.ts @@ -69,3 +69,40 @@ export const getCookieDomain = () => { return url.hostname; }; + +/** + * Get allowed signup domains from env var. + * Returns empty array if not set (meaning all domains allowed). + */ +export const getAllowedSignupDomains = (): string[] => { + const domains = env('NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS'); + + if (!domains) { + return []; + } + + return domains + .split(',') + .map((d) => d.trim().toLowerCase()) + .filter(Boolean); +}; + +/** + * Check if email domain is allowed for signup. + * Returns true if no domain restriction is configured. + */ +export const isEmailDomainAllowedForSignup = (email: string): boolean => { + const allowedDomains = getAllowedSignupDomains(); + + if (allowedDomains.length === 0) { + return true; + } + + const emailDomain = email.toLowerCase().split('@').pop(); + + if (!emailDomain) { + return false; + } + + return allowedDomains.includes(emailDomain); +}; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 4398d5fd2..0c208183c 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -74,6 +74,7 @@ declare namespace NodeJS { NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string; NEXT_PUBLIC_DISABLE_SIGNUP?: string; + NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS?: string; NEXT_PRIVATE_BROWSERLESS_URL?: string; diff --git a/turbo.json b/turbo.json index 011cf518c..dd6f7a538 100644 --- a/turbo.json +++ b/turbo.json @@ -48,6 +48,7 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_DISABLE_SIGNUP", + "NEXT_PRIVATE_ALLOWED_SIGNUP_DOMAINS", "NEXT_PRIVATE_PLAIN_API_KEY", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", "NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY",