fix(auth): stop offering account creation when registration is disabled (#6484)

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.
This commit is contained in:
Waleed
2026-08-09 22:02:42 -07:00
committed by GitHub
parent a72427f38d
commit 4b2412b752
22 changed files with 616 additions and 102 deletions
@@ -84,7 +84,7 @@ See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and th
| Variable | Effect |
|---|---|
| `DISABLE_REGISTRATION=true` | Blocks email/password registration |
| `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 |
| `DISABLE_EMAIL_SIGNUP=true` | Blocks new email/password registrations; existing email login keeps working |
| `ALLOWED_LOGIN_DOMAINS` | Comma-separated domain allowlist, e.g. `acme.com,acme.co.uk`. Gates email sign-**in** as well as signup |
| `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
| `BLOCKED_EMAIL_MX_HOSTS` | MX-host substrings to block; used only with the above |
<Callout type="warn">
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.
`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.
`DISABLE_REGISTRATION` and `BLOCKED_SIGNUP_DOMAINS` apply to every path, social included.
</Callout>
For a company deployment, the usual pairing is domain-restricted signup plus SSO:
@@ -107,7 +107,7 @@ See [Authentication](/platform/self-hosting/authentication).
| Variable | Description |
|----------|-------------|
| `DISABLE_REGISTRATION` | Set `true` to disable new user signups entirely |
| `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 |
| `DISABLE_EMAIL_SIGNUP` | Block new email/password registrations; existing email login keeps working |
| `ALLOWED_LOGIN_DOMAINS` | Restrict signups to domains (comma-separated) |
| `ALLOWED_LOGIN_EMAILS` | Restrict signups to specific emails (comma-separated) |
+51 -1
View File
@@ -2,7 +2,11 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect'
import {
buildAuthCrossLink,
resolveAuthRedirect,
resolvePostSignupDestination,
} from '@/app/(auth)/auth-redirect'
describe('resolvePostSignupDestination', () => {
it('routes to the verify hop when verification is enforceable', () => {
@@ -56,4 +60,50 @@ describe('buildAuthCrossLink', () => {
'/signup'
)
})
it('marks a new user so the invite page leads with account creation', () => {
expect(
buildAuthCrossLink('/signup', {
callbackUrl: '/invite/abc',
isInviteFlow: true,
isNewUser: true,
})
).toBe('/signup?invite_flow=true&callbackUrl=%2Finvite%2Fabc&new=true')
})
it('omits the new-user marker by default', () => {
expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: true })).not.toContain(
'new=true'
)
})
})
describe('resolveAuthRedirect', () => {
const NONE = { redirect: null, callbackUrl: null, inviteFlow: null }
it('prefers redirect over callbackUrl', () => {
expect(resolveAuthRedirect({ ...NONE, redirect: '/a', callbackUrl: '/b' }).rawCallbackUrl).toBe(
'/a'
)
})
it('falls through an empty redirect to callbackUrl', () => {
expect(
resolveAuthRedirect({ ...NONE, redirect: '', callbackUrl: '/invite/abc' }).rawCallbackUrl
).toBe('/invite/abc')
})
it('reports no destination when nothing was carried', () => {
expect(resolveAuthRedirect(NONE)).toEqual({ rawCallbackUrl: '', isInviteFlow: false })
})
it('treats an invitation destination as an invite flow without the flag', () => {
expect(resolveAuthRedirect({ ...NONE, callbackUrl: '/invite/abc' }).isInviteFlow).toBe(true)
})
it('honors the explicit flag when the destination is unrelated', () => {
expect(
resolveAuthRedirect({ ...NONE, callbackUrl: '/workspace', inviteFlow: 'true' }).isInviteFlow
).toBe(true)
})
})
+35 -1
View File
@@ -43,10 +43,43 @@ export function resolvePostSignupDestination({
return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
}
/** The raw redirect-carrying params, as read from a URL on client or server. */
interface AuthRedirectParams {
redirect: string | null
callbackUrl: string | null
inviteFlow: string | null
}
/**
* The post-auth destination a visitor arrived with, and whether they are mid
* invitation.
*
* `redirect` wins over `callbackUrl` — both spellings are in circulation. The
* invite flow is inferred from the destination as well as the explicit flag, so
* a link that lost `invite_flow` still reads as an invitation.
*
* Shared so the signup form and the registration-disabled page cannot drift on
* which param wins; both feed the result to {@link buildAuthCrossLink}. The
* caller validates — this function does not, so that a client can log the
* rejection it already reports.
*/
export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthRedirectParams): {
rawCallbackUrl: string
isInviteFlow: boolean
} {
const rawCallbackUrl = redirect || callbackUrl || ''
return {
rawCallbackUrl,
isInviteFlow: inviteFlow === 'true' || rawCallbackUrl.startsWith('/invite/'),
}
}
interface AuthCrossLinkParams {
/** Validated post-auth destination to carry over, or null to drop it. */
callbackUrl: string | null
isInviteFlow: boolean
/** Marks the visitor as new so the invite page leads with account creation. */
isNewUser?: boolean
}
/**
@@ -57,11 +90,12 @@ interface AuthCrossLinkParams {
*/
export function buildAuthCrossLink(
path: '/login' | '/signup',
{ callbackUrl, isInviteFlow }: AuthCrossLinkParams
{ callbackUrl, isInviteFlow, isNewUser = false }: AuthCrossLinkParams
): string {
const params = new URLSearchParams()
if (isInviteFlow) params.set('invite_flow', 'true')
if (callbackUrl) params.set('callbackUrl', callbackUrl)
if (isNewUser) params.set('new', 'true')
const query = params.toString()
return query ? `${path}?${query}` : path
+4 -1
View File
@@ -88,11 +88,14 @@ export default function LoginPage({
googleAvailable,
microsoftAvailable,
isProduction,
registrationDisabled,
}: {
githubAvailable: boolean
googleAvailable: boolean
microsoftAvailable: boolean
isProduction: boolean
/** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */
registrationDisabled: boolean
}) {
const router = useRouter()
const searchParams = useSearchParams()
@@ -436,7 +439,7 @@ export default function LoginPage({
</SocialLoginButtons>
)}
{emailEnabled && (
{emailEnabled && !registrationDisabled && (
<AuthNavPrompt prompt="Don't have an account?" href={signupHref} linkLabel='Sign up' />
)}
+2
View File
@@ -1,5 +1,6 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import LoginForm from '@/app/(auth)/login/login-form'
@@ -20,6 +21,7 @@ export default async function LoginPage() {
googleAvailable={googleAvailable}
microsoftAvailable={microsoftAvailable}
isProduction={isProduction}
registrationDisabled={isRegistrationDisabled}
/>
</Suspense>
)
+23 -2
View File
@@ -1,7 +1,12 @@
import type { Metadata } from 'next'
import type { SearchParams } from 'nuqs/server'
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
import { resolveAuthRedirect } from '@/app/(auth)/auth-redirect'
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
import { RegistrationDisabled } from '@/app/(auth)/signup/registration-disabled'
import { signupSearchParamsCache } from '@/app/(auth)/signup/search-params'
import SignupForm from '@/app/(auth)/signup/signup-form'
export const metadata: Metadata = {
@@ -10,9 +15,25 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic'
export default async function SignupPage() {
export default async function SignupPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
if (isRegistrationDisabled) {
return <div>Registration is disabled, please contact your admin.</div>
const { redirect, callbackUrl, inviteFlow } = await signupSearchParamsCache.parse(searchParams)
const { rawCallbackUrl, isInviteFlow } = resolveAuthRedirect({
redirect,
callbackUrl,
inviteFlow,
})
return (
<RegistrationDisabled
callbackUrl={validateCallbackUrl(rawCallbackUrl) ? rawCallbackUrl : null}
isInviteFlow={isInviteFlow}
/>
)
}
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
@@ -0,0 +1,31 @@
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components'
interface RegistrationDisabledProps {
/** Post-auth destination the visitor arrived with, already validated. */
callbackUrl: string | null
isInviteFlow: boolean
}
/**
* The signup page under DISABLE_REGISTRATION. Visitors reach it from a stale
* link, a bookmark, or an invitation, so it wears the same shell as the form it
* replaces and carries the post-auth destination over to login — an invited
* visitor who lands here can still sign in and end up back on their invitation
* rather than losing it.
*/
export function RegistrationDisabled({ callbackUrl, isInviteFlow }: RegistrationDisabledProps) {
return (
<div className='space-y-6'>
<AuthHeader
title='Account creation is disabled'
description='Ask your admin to create an account for you.'
/>
<AuthNavPrompt
prompt='Already have an account?'
href={buildAuthCrossLink('/login', { callbackUrl, isInviteFlow })}
linkLabel='Sign in'
/>
</div>
)
}
@@ -0,0 +1,23 @@
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
/**
* The redirect signals the signup page carries. Read once to decide where a
* visitor goes after authenticating, never written, so every parser is nullable
* with no default — absent means "no destination", which is a real state rather
* than something to fall back from.
*/
const signupParsers = {
redirect: parseAsString,
callbackUrl: parseAsString,
inviteFlow: parseAsString,
} as const
/** `invite_flow` on the wire; camelCase for destructuring. */
const signupUrlKeys = { urlKeys: { inviteFlow: 'invite_flow' } } as const
/**
* Server-side reader for the signup page. The client form reads these same keys
* through `useSearchParams` (the read-once auth-signal carve-out), so the wire
* keys here and in `signup-form.tsx` must stay in step.
*/
export const signupSearchParamsCache = createSearchParamsCache(signupParsers, signupUrlKeys)
+7 -6
View File
@@ -1,6 +1,6 @@
'use client'
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { Suspense, useEffect, useRef, useState } from 'react'
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -15,6 +15,7 @@ import {
buildAuthCrossLink,
DEFAULT_POST_AUTH_ROUTE,
POST_AUTH_REDIRECT_STORAGE_KEY,
resolveAuthRedirect,
resolvePostSignupDestination,
VERIFY_FROM_SIGNUP_ROUTE,
} from '@/app/(auth)/auth-redirect'
@@ -123,7 +124,11 @@ function SignupFormContent({
const [formError, setFormError] = useState<string | null>(null)
const turnstileRef = useRef<TurnstileInstance>(null)
const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
const { rawCallbackUrl: rawRedirectUrl, isInviteFlow } = resolveAuthRedirect({
redirect: searchParams.get('redirect'),
callbackUrl: searchParams.get('callbackUrl'),
inviteFlow: searchParams.get('invite_flow'),
})
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
const invalidCallbackRef = useRef(false)
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
@@ -131,10 +136,6 @@ function SignupFormContent({
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
}
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
const isInviteFlow = useMemo(
() => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'),
[searchParams, redirectUrl]
)
const [name, setName] = useState('')
const [nameErrors, setNameErrors] = useState<string[]>([])
+2 -2
View File
@@ -1,7 +1,7 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags'
import SSOForm from '@/ee/sso/components/sso-form'
export const metadata: Metadata = {
@@ -17,7 +17,7 @@ export default async function SSOPage() {
return (
<Suspense fallback={null}>
<SSOForm />
<SSOForm registrationDisabled={isRegistrationDisabled} />
</Suspense>
)
}
+71
View File
@@ -0,0 +1,71 @@
/**
* @vitest-environment node
*/
import { envFlagsMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockRedirect } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockRedirect: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`)
}),
}))
vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
getSession: mockGetSession,
}))
vi.mock('next/navigation', () => ({
redirect: mockRedirect,
}))
import CliAuthPage from '@/app/cli/auth/page'
/** BASE64URL, 43 chars; pairing is `XXXX-XXXX` over the no-look-alike alphabet. */
const REQUEST = 'r'.repeat(43)
const CHALLENGE = 'c'.repeat(43)
const PAIRING = 'ABCD-2345'
const EXPECTED_CALLBACK = encodeURIComponent(
`/cli/auth?request=${REQUEST}&challenge=${CHALLENGE}&pairing=${PAIRING}`
)
function pageProps() {
return {
searchParams: Promise.resolve({
request: REQUEST,
challenge: CHALLENGE,
pairing: PAIRING,
}),
}
}
describe('CliAuthPage signed-out bounce', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue(null)
})
afterEach(() => {
envFlagsMock.isRegistrationDisabled = false
})
it('sends a signed-out visitor to signup, carrying the handoff as callbackUrl', async () => {
await expect(CliAuthPage(pageProps())).rejects.toThrow(
`NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}`
)
})
/**
* Nobody can create an account under the flag, so the pairing visitor is
* necessarily an existing user and signup would be a guaranteed dead end.
*/
it('sends them to login instead when registration is disabled', async () => {
envFlagsMock.isRegistrationDisabled = true
await expect(CliAuthPage(pageProps())).rejects.toThrow(
`NEXT_REDIRECT:/login?callbackUrl=${EXPECTED_CALLBACK}`
)
})
})
+12 -1
View File
@@ -3,6 +3,8 @@ import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import type { SearchParams } from 'nuqs/server'
import { getSession } from '@/lib/auth'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { AuthShell } from '@/app/(auth)/components'
import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request'
import { CliAuthView } from '@/app/cli/auth/cli-auth-view'
@@ -30,6 +32,10 @@ export const dynamic = 'force-dynamic'
* configuring Sim for the first time has no account yet. Both auth pages
* cross-link carrying the same `callbackUrl`, so a returning user is one click
* from login with their destination intact.
*
* That reasoning inverts under DISABLE_REGISTRATION: nobody can create an
* account, so the pairing visitor is necessarily an existing user and signup is
* guaranteed to be the wrong hop. Go straight to login there.
*/
export default async function CliAuthPage({
searchParams,
@@ -49,7 +55,12 @@ export default async function CliAuthPage({
challenge: resolution.request.challenge,
pairing: resolution.request.pairing,
})
redirect(`/signup?callbackUrl=${encodeURIComponent(`/cli/auth?${query}`)}`)
redirect(
buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', {
callbackUrl: `/cli/auth?${query}`,
isInviteFlow: false,
})
)
}
return (
+65 -10
View File
@@ -27,9 +27,7 @@ const {
},
mockPush: vi.fn(),
mockRequestJson: vi.fn(),
mockSearchParams: {
get: (key: string) => (key === 'token' ? 'token-1' : null),
},
mockSearchParams: { current: new URLSearchParams('token=token-1') },
mockSetActive: vi.fn(),
mockSetQueryData: vi.fn(),
mockSignOut: vi.fn(),
@@ -43,7 +41,7 @@ vi.mock('@sim/logger', () => ({
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'invitation-1' }),
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
useSearchParams: () => mockSearchParams.current,
}))
vi.mock('@tanstack/react-query', async () => {
@@ -106,15 +104,18 @@ vi.mock('@/app/invite/components', () => ({
InviteLayout: ({ children }: { children: ReactNode }) => children,
InviteStatusCard: ({
actions = [],
description,
title,
type,
}: {
actions?: Array<{ label: string; onClick: () => void }>
description?: ReactNode
title: string
type: string
}) => (
<>
<div data-invite-status={type}>{title}</div>
<div>{description}</div>
{actions.map((action) => (
<button key={action.label} type='button' onClick={action.onClick}>
{action.label}
@@ -149,24 +150,40 @@ async function flush(): Promise<void> {
})
}
async function acceptCurrentInvitation(): Promise<void> {
async function renderInvite(registrationDisabled = false): Promise<void> {
act(() => {
root.render(<Invite />)
root.render(<Invite registrationDisabled={registrationDisabled} />)
})
await flush()
}
const acceptButton = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === 'Accept Invitation'
async function renderSignedOut(registrationDisabled: boolean): Promise<void> {
mockUseSession.mockReturnValue({ data: null, isPending: false })
await renderInvite(registrationDisabled)
}
function actionLabels(): string[] {
return Array.from(container.querySelectorAll('button'), (button) => button.textContent ?? '')
}
async function clickAction(label: string): Promise<void> {
const action = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === label
)
expect(acceptButton).toBeDefined()
expect(action).toBeDefined()
await act(async () => {
acceptButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
action?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
await Promise.resolve()
})
}
async function acceptCurrentInvitation(): Promise<void> {
await renderInvite()
await clickAction('Accept Invitation')
}
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
@@ -174,6 +191,7 @@ beforeEach(() => {
document.body.appendChild(container)
root = createRoot(container)
mockSearchParams.current = new URLSearchParams('token=token-1')
mockUseSession.mockReturnValue({
data: { user: { id: 'user-1', email: 'invitee@example.com' } },
isPending: false,
@@ -283,4 +301,41 @@ describe('Invite', () => {
})
expect(mockLogger.warn).toHaveBeenCalledTimes(4)
})
/**
* Every case marks the visitor as new (`new=true`), the state that leads with
* "Create an account" when registration is enabled — so each assertion below
* fails if the flag stops being honored.
*/
describe('signed out with registration disabled', () => {
beforeEach(() => {
mockSearchParams.current = new URLSearchParams('token=token-1&new=true')
})
it('offers only sign-in, since /signup would reject the visitor', async () => {
await renderSignedOut(true)
expect(actionLabels()).toEqual(['Sign in', 'Return to Home'])
expect(container.textContent).toContain('Account creation is disabled on this instance')
})
it('sends the visitor to login with the invitation as the callback', async () => {
await renderSignedOut(true)
await clickAction('Sign in')
expect(mockPush).toHaveBeenCalledWith(
`/login?invite_flow=true&callbackUrl=${encodeURIComponent('/invite/invitation-1?token=token-1')}`
)
})
it('still offers account creation when registration is enabled', async () => {
await renderSignedOut(false)
expect(actionLabels()).toEqual([
'Create an account',
'I already have an account',
'Return to Home',
])
})
})
})
+102 -43
View File
@@ -10,6 +10,7 @@ import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { acceptInvitationContract } from '@/lib/api/contracts/invitations'
import { client, useSession } from '@/lib/auth/auth-client'
import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect'
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
import { useInvitationDetails } from '@/hooks/queries/invitations'
import { organizationKeys } from '@/hooks/queries/organization'
@@ -22,6 +23,80 @@ const logger = createLogger('InviteById')
/** Workspace names listed in the invitation title before collapsing into an "and N more" tail. */
const MAX_LISTED_WORKSPACE_NAMES = 3
/**
* Goes through the shared builder so the invite page cannot drift from the
* cross-link shape the auth pages use.
*/
function inviteAuthLink(
path: '/login' | '/signup',
callbackUrl: string,
isNewUser = false
): string {
return buildAuthCrossLink(path, { callbackUrl, isInviteFlow: true, isNewUser })
}
interface InviteAction {
label: string
onClick: () => void
}
interface SignedOutPromptParams {
registrationDisabled: boolean
isNewUser: boolean
callbackUrl: string
navigate: (href: string) => void
}
/**
* What a signed-out visitor is offered, as one branch so the copy and the
* buttons under it can never disagree about what they may do.
*
* Under DISABLE_REGISTRATION only signing in is possible — `/signup` rejects
* the visitor server-side, so offering it would be a dead end.
*/
function signedOutPrompt({
registrationDisabled,
isNewUser,
callbackUrl,
navigate,
}: SignedOutPromptParams): { description: string; actions: InviteAction[] } {
const signIn: InviteAction = {
label: 'Sign in',
onClick: () => navigate(inviteAuthLink('/login', callbackUrl)),
}
if (registrationDisabled) {
return {
description: 'Account creation is disabled on this instance',
actions: [signIn],
}
}
if (isNewUser) {
return {
description: 'Create an account to join this workspace on Sim',
actions: [
{
label: 'Create an account',
onClick: () => navigate(inviteAuthLink('/signup', callbackUrl)),
},
{ ...signIn, label: 'I already have an account' },
],
}
}
return {
description: 'Sign in to your account to accept this invitation',
actions: [
signIn,
{
label: 'Create an account',
onClick: () => navigate(inviteAuthLink('/signup', callbackUrl, true)),
},
],
}
}
function runBestEffortCacheRefresh(cache: string, refresh: () => Promise<unknown>): void {
void Promise.resolve()
.then(refresh)
@@ -189,7 +264,12 @@ function codeFromApiClientError(error: ApiClientError): string {
return codeFromStatus(error.status)
}
export default function Invite() {
interface InviteProps {
/** DISABLE_REGISTRATION. See {@link signedOutPrompt}. */
registrationDisabled: boolean
}
export default function Invite({ registrationDisabled }: InviteProps) {
const router = useRouter()
const params = useParams()
const inviteId = params.id as string
@@ -314,48 +394,23 @@ export default function Invite() {
}
if (!session?.user && !isPending) {
const callbackUrl = encodeURIComponent(getCallbackUrl())
const prompt = signedOutPrompt({
registrationDisabled,
isNewUser,
callbackUrl: getCallbackUrl(),
navigate: router.push,
})
return (
<InviteLayout>
<InviteStatusCard
type='login'
title="You've been invited!"
description={
isNewUser
? 'Create an account to join this workspace on Sim'
: 'Sign in to your account to accept this invitation'
}
description={prompt.description}
icon='userPlus'
actions={[
...(isNewUser
? [
{
label: 'Create an account',
onClick: () =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
},
{
label: 'I already have an account',
onClick: () =>
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
},
]
: [
{
label: 'Sign in',
onClick: () =>
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
},
{
label: 'Create an account',
onClick: () =>
router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true&new=true`),
},
]),
{
label: 'Return to Home',
onClick: () => router.push('/'),
},
...prompt.actions,
{ label: 'Return to Home', onClick: () => router.push('/') },
]}
/>
</InviteLayout>
@@ -371,7 +426,7 @@ export default function Invite() {
}
if (error) {
const callbackUrl = encodeURIComponent(getCallbackUrl())
const callbackUrl = getCallbackUrl()
if (error.code === 'email-mismatch') {
return (
@@ -386,7 +441,7 @@ export default function Invite() {
label: 'Sign in with a different account',
onClick: async () => {
await client.signOut()
router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`)
router.push(inviteAuthLink('/login', callbackUrl))
},
},
{ label: 'Return to Home', onClick: () => router.push('/') },
@@ -424,12 +479,16 @@ export default function Invite() {
actions={[
{
label: 'Sign in to continue',
onClick: () => router.push(`/login?callbackUrl=${callbackUrl}&invite_flow=true`),
},
{
label: 'Create an account',
onClick: () => router.push(`/signup?callbackUrl=${callbackUrl}&invite_flow=true`),
onClick: () => router.push(inviteAuthLink('/login', callbackUrl)),
},
...(registrationDisabled
? []
: [
{
label: 'Create an account',
onClick: () => router.push(inviteAuthLink('/signup', callbackUrl)),
},
]),
{ label: 'Return to Home', onClick: () => router.push('/') },
]}
/>
+2 -1
View File
@@ -1,5 +1,6 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
import Invite from '@/app/invite/[id]/invite'
export const metadata: Metadata = {
@@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic'
export default function InvitePage() {
return (
<Suspense fallback={null}>
<Invite />
<Invite registrationDisabled={isRegistrationDisabled} />
</Suspense>
)
}
+8
View File
@@ -25,6 +25,14 @@ interface OAuthErrorPageProps {
const FRIENDLY: Record<string, string> = {
access_denied: 'You declined the request at the provider, so nothing was connected.',
oAuth_code_missing: 'The provider didn’t return a valid response. Please try again.',
/**
* DISABLE_REGISTRATION rejecting a first-time social sign-in. Better Auth
* reports this as `signup disabled`, which it slugs into the `error` param.
* Without a message here the visitor is told to "try again", which can never
* succeed.
*/
signup_disabled:
'Account creation is disabled on this instance. Ask your admin to create an account for you.',
}
function messageForError(code: string | undefined): string {
+23 -2
View File
@@ -44,9 +44,9 @@ vi.mock('@/lib/core/config/env', () => ({
import SSOForm from '@/ee/sso/components/sso-form'
function renderFirstFrame(search: string): string {
function renderFirstFrame(search: string, registrationDisabled = false): string {
mockUseSearchParams.mockReturnValue(new URLSearchParams(search))
return renderToString(<SSOForm />)
return renderToString(<SSOForm registrationDisabled={registrationDisabled} />)
}
/**
@@ -78,3 +78,24 @@ describe('SSOForm callback URL', () => {
expect(html).toContain(`/login?callbackUrl=${encodeURIComponent('/workspace')}`)
})
})
describe('SSOForm signup cross-link', () => {
beforeEach(() => {
mockUseSearchParams.mockReset()
})
it('offers signup when registration is enabled', () => {
const html = renderFirstFrame('')
expect(html).toContain('Don&#x27;t have an account?')
expect(html).toContain('/signup')
})
/** `/signup` rejects the visitor server-side, so linking there is a dead end. */
it('hides signup when registration is disabled', () => {
const html = renderFirstFrame('', true)
expect(html).not.toContain('Don&#x27;t have an account?')
expect(html).not.toContain('/signup')
})
})
+7 -2
View File
@@ -29,7 +29,12 @@ const validateEmailField = (emailValue: string): string[] => {
return errors
}
export default function SSOForm() {
interface SSOFormProps {
/** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */
registrationDisabled: boolean
}
export default function SSOForm({ registrationDisabled }: SSOFormProps) {
const router = useRouter()
const searchParams = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
@@ -215,7 +220,7 @@ export default function SSOForm() {
</>
)}
{emailEnabled && (
{emailEnabled && !registrationDisabled && (
<div className='pt-6 text-center font-light text-base'>
<span className='font-normal'>Don't have an account? </span>
<Link
+39 -27
View File
@@ -31,7 +31,11 @@ import {
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
import { getRequestedSignInProviderId, isSignInProviderAllowed } from '@/lib/auth/constants'
import {
applyRegistrationGate,
getRequestedSignInProviderId,
isSignInProviderAllowed,
} from '@/lib/auth/constants'
import { getSessionCookieCacheVersion } from '@/lib/auth/security-policy'
import { clampExpiryForSession } from '@/lib/auth/session-policy'
import { guardSubscriptionPlanWrites } from '@/lib/auth/stripe-adapter-guard'
@@ -714,34 +718,42 @@ export const auth = betterAuth({
],
},
},
socialProviders: {
...(!isGithubAuthDisabled && {
github: {
clientId: env.GITHUB_CLIENT_ID as string,
clientSecret: env.GITHUB_CLIENT_SECRET as string,
scope: ['user:email', 'repo'],
},
}),
...(!isGoogleAuthDisabled && {
google: {
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
],
},
}),
...(!isMicrosoftAuthDisabled &&
env.MICROSOFT_CLIENT_ID &&
env.MICROSOFT_CLIENT_SECRET && {
microsoft: {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
scope: ['openid', 'profile', 'email'],
/**
* SSO is deliberately outside the registration gate: it runs on
* `/sign-in/sso` against admin-configured, domain-verified providers, which
* is its own allowlist.
*/
socialProviders: applyRegistrationGate(
{
...(!isGithubAuthDisabled && {
github: {
clientId: env.GITHUB_CLIENT_ID as string,
clientSecret: env.GITHUB_CLIENT_SECRET as string,
scope: ['user:email', 'repo'],
},
}),
},
...(!isGoogleAuthDisabled && {
google: {
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
],
},
}),
...(!isMicrosoftAuthDisabled &&
env.MICROSOFT_CLIENT_ID &&
env.MICROSOFT_CLIENT_SECRET && {
microsoft: {
clientId: env.MICROSOFT_CLIENT_ID,
clientSecret: env.MICROSOFT_CLIENT_SECRET,
scope: ['openid', 'profile', 'email'],
},
}),
},
isRegistrationDisabled
),
emailVerification: {
autoSignInAfterVerification: true,
afterEmailVerification: async (user) => {
+50
View File
@@ -3,6 +3,7 @@
*/
import { describe, expect, it } from 'vitest'
import {
applyRegistrationGate,
getRequestedSignInProviderId,
isSignInProviderAllowed,
SIGN_IN_PROVIDER_IDS,
@@ -84,3 +85,52 @@ describe('getRequestedSignInProviderId', () => {
expect(getRequestedSignInProviderId('/sign-in/oauth2', null)).toBeUndefined()
})
})
describe('registration gate', () => {
const providers = {
google: { clientId: 'g', clientSecret: 'gs', scope: ['email'] },
github: { clientId: 'h', clientSecret: 'hs' },
}
it('leaves the provider map untouched when registration is enabled', () => {
const gated = applyRegistrationGate(providers, false)
expect(gated).toBe(providers)
expect(gated.google).not.toHaveProperty('disableSignUp')
})
it('blocks account creation on every provider when registration is disabled', () => {
const gated = applyRegistrationGate(providers, true)
for (const config of Object.values(gated)) {
expect(config.disableSignUp).toBe(true)
}
})
/**
* The id-token branch of `/sign-in/social` reads a top-level
* `provider.disableSignUp` that Better Auth never hoists from config, so
* `disableSignUp` alone leaves that entrance open.
*/
it('also closes the id-token sign-in entrance', () => {
const gated = applyRegistrationGate(providers, true)
for (const config of Object.values(gated)) {
expect(config.disableIdTokenSignIn).toBe(true)
}
})
it('preserves each provider credential and its keys', () => {
const gated = applyRegistrationGate(providers, true)
expect(Object.keys(gated)).toEqual(['google', 'github'])
expect(gated.google).toMatchObject({ clientId: 'g', clientSecret: 'gs', scope: ['email'] })
})
it('gates a provider added later without it opting in', () => {
const gated = applyRegistrationGate({ ...providers, someFutureIdp: { clientId: 'f' } }, true)
expect(gated.someFutureIdp.disableSignUp).toBe(true)
expect(gated.someFutureIdp.disableIdTokenSignIn).toBe(true)
})
})
+54
View File
@@ -52,3 +52,57 @@ export function getRequestedSignInProviderId(
if (path === '/sign-in/oauth2') return body?.providerId
return undefined
}
/**
* A social provider config, narrowed to the Better Auth options that turn off
* account creation. The index signature carries the rest of the config
* (`clientId`, `scope`, …) untyped — only the two gate keys matter here, and
* typing them catches a rename in a Better Auth upgrade.
*/
interface RegistrationGate {
disableSignUp?: boolean
disableIdTokenSignIn?: boolean
[option: string]: unknown
}
/**
* Stamps DISABLE_REGISTRATION onto every social provider config.
*
* The `/sign-up*` gate cannot see OAuth account creation, which happens on
* `/sign-in/social` and its callback — without this, a registration-disabled
* deployment still mints accounts for any unknown Google/GitHub/Microsoft
* identity. Stamping the whole map rather than each provider literal means a
* provider added later inherits the gate instead of silently reopening the
* hole; that is the entire point of doing this here rather than inline.
*
* Both keys are required because Better Auth resolves the gate differently per
* entrance. The redirect callback reads `provider.options.disableSignUp`, but
* the id-token branch of `/sign-in/social` reads a **top-level**
* `provider.disableSignUp` that is never hoisted from config, so `disableSignUp`
* alone leaves that entrance open. `disableIdTokenSignIn` closes it by failing
* `verifyIdToken` before any user lookup. Sim only ever uses the redirect flow,
* so disabling the id-token path costs nothing today.
*
* Existing users are unaffected on both paths — the gate only rejects when no
* account matches the verified identity.
*
* Returns the map untouched when the flag is off, so a registration-enabled
* deployment hands Better Auth the exact object it had before. Better Auth also
* accepts a lazily-evaluated (function) provider config, which this could not
* stamp — spreading a function drops its credentials — but {@link
* RegistrationGate}'s index signature makes that a compile error, so it cannot
* reach here ungated.
*/
export function applyRegistrationGate<T extends Record<string, RegistrationGate>>(
providers: T,
registrationDisabled: boolean
): T {
if (!registrationDisabled) return providers
const gated: Record<string, RegistrationGate> = {}
for (const [id, config] of Object.entries(providers)) {
gated[id] = { ...config, disableSignUp: true, disableIdTokenSignIn: true }
}
/** The spread widens past what TypeScript can prove; the keys are unchanged. */
return gated as T
}