mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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('/') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'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't have an account?')
|
||||
expect(html).not.toContain('/signup')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user