Skip to content

Commit 23bec77

Browse files
committed
fix(auth): stop offering account creation when registration is disabled
DISABLE_REGISTRATION blocks /signup server-side, but the invite flow, the login form, the SSO form, and the CLI handoff all kept routing people there, stranding invited users on a dead end. The flag also never covered OAuth account creation, so social sign-in still minted accounts for unknown identities.
1 parent a554430 commit 23bec77

22 files changed

Lines changed: 616 additions & 102 deletions

File tree

apps/docs/content/docs/en/platform/self-hosting/authentication.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
8484

8585
| Variable | Effect |
8686
|---|---|
87-
| `DISABLE_REGISTRATION=true` | Blocks email/password registration |
87+
| `DISABLE_REGISTRATION=true` | Blocks all new accounts — email/password, email OTP, and social sign-in. Only existing accounts can sign in, including to accept a workspace invitation. SSO is unaffected |
8888
| `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working |
8989
| `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup |
9090
| `ALLOWED_LOGIN_EMAILS` | Comma-separated address allowlist, applied the same way |
@@ -93,7 +93,9 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
9393
| `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above |
9494

9595
<Callout type="warn">
96-
These controls gate the **email/password** path. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. If you need a hard boundary, disable the social providers you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO.
96+
`ALLOWED_LOGIN_DOMAINS`, `ALLOWED_LOGIN_EMAILS`, and `SIGNUP_MX_VALIDATION_ENABLED` gate the **email/password** path only. A first-time sign-in through Google, GitHub, or Microsoft creates an account through the social provider and is not filtered by them. To restrict who may sign in through a social provider, disable the ones you have not vetted (`DISABLE_GOOGLE_AUTH`, `DISABLE_GITHUB_AUTH`, `DISABLE_MICROSOFT_AUTH`) or restrict membership at the identity provider and use SSO.
97+
98+
`DISABLE_REGISTRATION` and `BLOCKED_SIGNUP_DOMAINS` apply to every path, social included.
9799
</Callout>
98100

99101
For a company deployment, the usual pairing is domain-restricted signup plus SSO:

apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ See [Authentication](/platform/self-hosting/authentication).
107107

108108
| Variable | Description |
109109
|----------|-------------|
110-
| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely |
110+
| `DISABLE_REGISTRATION` | Set `true` to block all new accounts, including social sign-in. Invitations still work for people who already have an account. SSO is unaffected |
111111
| `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working |
112112
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
113113
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |

apps/sim/app/(auth)/auth-redirect.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect'
5+
import {
6+
buildAuthCrossLink,
7+
resolveAuthRedirect,
8+
resolvePostSignupDestination,
9+
} from '@/app/(auth)/auth-redirect'
610

711
describe('resolvePostSignupDestination', () => {
812
it('routes to the verify hop when verification is enforceable', () => {
@@ -56,4 +60,50 @@ describe('buildAuthCrossLink', () => {
5660
'/signup'
5761
)
5862
})
63+
64+
it('marks a new user so the invite page leads with account creation', () => {
65+
expect(
66+
buildAuthCrossLink('/signup', {
67+
callbackUrl: '/invite/abc',
68+
isInviteFlow: true,
69+
isNewUser: true,
70+
})
71+
).toBe('/signup?invite_flow=true&callbackUrl=%2Finvite%2Fabc&new=true')
72+
})
73+
74+
it('omits the new-user marker by default', () => {
75+
expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: true })).not.toContain(
76+
'new=true'
77+
)
78+
})
79+
})
80+
81+
describe('resolveAuthRedirect', () => {
82+
const NONE = { redirect: null, callbackUrl: null, inviteFlow: null }
83+
84+
it('prefers redirect over callbackUrl', () => {
85+
expect(resolveAuthRedirect({ ...NONE, redirect: '/a', callbackUrl: '/b' }).rawCallbackUrl).toBe(
86+
'/a'
87+
)
88+
})
89+
90+
it('falls through an empty redirect to callbackUrl', () => {
91+
expect(
92+
resolveAuthRedirect({ ...NONE, redirect: '', callbackUrl: '/invite/abc' }).rawCallbackUrl
93+
).toBe('/invite/abc')
94+
})
95+
96+
it('reports no destination when nothing was carried', () => {
97+
expect(resolveAuthRedirect(NONE)).toEqual({ rawCallbackUrl: '', isInviteFlow: false })
98+
})
99+
100+
it('treats an invitation destination as an invite flow without the flag', () => {
101+
expect(resolveAuthRedirect({ ...NONE, callbackUrl: '/invite/abc' }).isInviteFlow).toBe(true)
102+
})
103+
104+
it('honors the explicit flag when the destination is unrelated', () => {
105+
expect(
106+
resolveAuthRedirect({ ...NONE, callbackUrl: '/workspace', inviteFlow: 'true' }).isInviteFlow
107+
).toBe(true)
108+
})
59109
})

apps/sim/app/(auth)/auth-redirect.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,43 @@ export function resolvePostSignupDestination({
4343
return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
4444
}
4545

46+
/** The raw redirect-carrying params, as read from a URL on client or server. */
47+
interface AuthRedirectParams {
48+
redirect: string | null
49+
callbackUrl: string | null
50+
inviteFlow: string | null
51+
}
52+
53+
/**
54+
* The post-auth destination a visitor arrived with, and whether they are mid
55+
* invitation.
56+
*
57+
* `redirect` wins over `callbackUrl` — both spellings are in circulation. The
58+
* invite flow is inferred from the destination as well as the explicit flag, so
59+
* a link that lost `invite_flow` still reads as an invitation.
60+
*
61+
* Shared so the signup form and the registration-disabled page cannot drift on
62+
* which param wins; both feed the result to {@link buildAuthCrossLink}. The
63+
* caller validates — this function does not, so that a client can log the
64+
* rejection it already reports.
65+
*/
66+
export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthRedirectParams): {
67+
rawCallbackUrl: string
68+
isInviteFlow: boolean
69+
} {
70+
const rawCallbackUrl = redirect || callbackUrl || ''
71+
return {
72+
rawCallbackUrl,
73+
isInviteFlow: inviteFlow === 'true' || rawCallbackUrl.startsWith('/invite/'),
74+
}
75+
}
76+
4677
interface AuthCrossLinkParams {
4778
/** Validated post-auth destination to carry over, or null to drop it. */
4879
callbackUrl: string | null
4980
isInviteFlow: boolean
81+
/** Marks the visitor as new so the invite page leads with account creation. */
82+
isNewUser?: boolean
5083
}
5184

5285
/**
@@ -57,11 +90,12 @@ interface AuthCrossLinkParams {
5790
*/
5891
export function buildAuthCrossLink(
5992
path: '/login' | '/signup',
60-
{ callbackUrl, isInviteFlow }: AuthCrossLinkParams
93+
{ callbackUrl, isInviteFlow, isNewUser = false }: AuthCrossLinkParams
6194
): string {
6295
const params = new URLSearchParams()
6396
if (isInviteFlow) params.set('invite_flow', 'true')
6497
if (callbackUrl) params.set('callbackUrl', callbackUrl)
98+
if (isNewUser) params.set('new', 'true')
6599

66100
const query = params.toString()
67101
return query ? `${path}?${query}` : path

apps/sim/app/(auth)/login/login-form.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,14 @@ export default function LoginPage({
8888
googleAvailable,
8989
microsoftAvailable,
9090
isProduction,
91+
registrationDisabled,
9192
}: {
9293
githubAvailable: boolean
9394
googleAvailable: boolean
9495
microsoftAvailable: boolean
9596
isProduction: boolean
97+
/** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */
98+
registrationDisabled: boolean
9699
}) {
97100
const router = useRouter()
98101
const searchParams = useSearchParams()
@@ -436,7 +439,7 @@ export default function LoginPage({
436439
</SocialLoginButtons>
437440
)}
438441

439-
{emailEnabled && (
442+
{emailEnabled && !registrationDisabled && (
440443
<AuthNavPrompt prompt="Don't have an account?" href={signupHref} linkLabel='Sign up' />
441444
)}
442445

apps/sim/app/(auth)/login/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Suspense } from 'react'
22
import type { Metadata } from 'next'
3+
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
34
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
45
import LoginForm from '@/app/(auth)/login/login-form'
56

@@ -20,6 +21,7 @@ export default async function LoginPage() {
2021
googleAvailable={googleAvailable}
2122
microsoftAvailable={microsoftAvailable}
2223
isProduction={isProduction}
24+
registrationDisabled={isRegistrationDisabled}
2325
/>
2426
</Suspense>
2527
)

apps/sim/app/(auth)/signup/page.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { Metadata } from 'next'
2+
import type { SearchParams } from 'nuqs/server'
23
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
4+
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
35
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
6+
import { resolveAuthRedirect } from '@/app/(auth)/auth-redirect'
47
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
8+
import { RegistrationDisabled } from '@/app/(auth)/signup/registration-disabled'
9+
import { signupSearchParamsCache } from '@/app/(auth)/signup/search-params'
510
import SignupForm from '@/app/(auth)/signup/signup-form'
611

712
export const metadata: Metadata = {
@@ -10,9 +15,25 @@ export const metadata: Metadata = {
1015

1116
export const dynamic = 'force-dynamic'
1217

13-
export default async function SignupPage() {
18+
export default async function SignupPage({
19+
searchParams,
20+
}: {
21+
searchParams: Promise<SearchParams>
22+
}) {
1423
if (isRegistrationDisabled) {
15-
return <div>Registration is disabled, please contact your admin.</div>
24+
const { redirect, callbackUrl, inviteFlow } = await signupSearchParamsCache.parse(searchParams)
25+
const { rawCallbackUrl, isInviteFlow } = resolveAuthRedirect({
26+
redirect,
27+
callbackUrl,
28+
inviteFlow,
29+
})
30+
31+
return (
32+
<RegistrationDisabled
33+
callbackUrl={validateCallbackUrl(rawCallbackUrl) ? rawCallbackUrl : null}
34+
isInviteFlow={isInviteFlow}
35+
/>
36+
)
1637
}
1738

1839
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
2+
import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components'
3+
4+
interface RegistrationDisabledProps {
5+
/** Post-auth destination the visitor arrived with, already validated. */
6+
callbackUrl: string | null
7+
isInviteFlow: boolean
8+
}
9+
10+
/**
11+
* The signup page under DISABLE_REGISTRATION. Visitors reach it from a stale
12+
* link, a bookmark, or an invitation, so it wears the same shell as the form it
13+
* replaces and carries the post-auth destination over to login — an invited
14+
* visitor who lands here can still sign in and end up back on their invitation
15+
* rather than losing it.
16+
*/
17+
export function RegistrationDisabled({ callbackUrl, isInviteFlow }: RegistrationDisabledProps) {
18+
return (
19+
<div className='space-y-6'>
20+
<AuthHeader
21+
title='Account creation is disabled'
22+
description='Ask your admin to create an account for you.'
23+
/>
24+
<AuthNavPrompt
25+
prompt='Already have an account?'
26+
href={buildAuthCrossLink('/login', { callbackUrl, isInviteFlow })}
27+
linkLabel='Sign in'
28+
/>
29+
</div>
30+
)
31+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
2+
3+
/**
4+
* The redirect signals the signup page carries. Read once to decide where a
5+
* visitor goes after authenticating, never written, so every parser is nullable
6+
* with no default — absent means "no destination", which is a real state rather
7+
* than something to fall back from.
8+
*/
9+
const signupParsers = {
10+
redirect: parseAsString,
11+
callbackUrl: parseAsString,
12+
inviteFlow: parseAsString,
13+
} as const
14+
15+
/** `invite_flow` on the wire; camelCase for destructuring. */
16+
const signupUrlKeys = { urlKeys: { inviteFlow: 'invite_flow' } } as const
17+
18+
/**
19+
* Server-side reader for the signup page. The client form reads these same keys
20+
* through `useSearchParams` (the read-once auth-signal carve-out), so the wire
21+
* keys here and in `signup-form.tsx` must stay in step.
22+
*/
23+
export const signupSearchParamsCache = createSearchParamsCache(signupParsers, signupUrlKeys)

apps/sim/app/(auth)/signup/signup-form.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
3+
import { Suspense, useEffect, useRef, useState } from 'react'
44
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
55
import { createLogger } from '@sim/logger'
66
import { useRouter, useSearchParams } from 'next/navigation'
@@ -15,6 +15,7 @@ import {
1515
buildAuthCrossLink,
1616
DEFAULT_POST_AUTH_ROUTE,
1717
POST_AUTH_REDIRECT_STORAGE_KEY,
18+
resolveAuthRedirect,
1819
resolvePostSignupDestination,
1920
VERIFY_FROM_SIGNUP_ROUTE,
2021
} from '@/app/(auth)/auth-redirect'
@@ -123,18 +124,18 @@ function SignupFormContent({
123124
const [formError, setFormError] = useState<string | null>(null)
124125
const turnstileRef = useRef<TurnstileInstance>(null)
125126
const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
126-
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
127+
const { rawCallbackUrl: rawRedirectUrl, isInviteFlow } = resolveAuthRedirect({
128+
redirect: searchParams.get('redirect'),
129+
callbackUrl: searchParams.get('callbackUrl'),
130+
inviteFlow: searchParams.get('invite_flow'),
131+
})
127132
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
128133
const invalidCallbackRef = useRef(false)
129134
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
130135
invalidCallbackRef.current = true
131136
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
132137
}
133138
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
134-
const isInviteFlow = useMemo(
135-
() => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'),
136-
[searchParams, redirectUrl]
137-
)
138139

139140
const [name, setName] = useState('')
140141
const [nameErrors, setNameErrors] = useState<string[]>([])

0 commit comments

Comments
 (0)