Skip to content

Commit 7ede958

Browse files
fix(auth): skip email verification when no mail provider is configured
Signup pushed /verify unconditionally, stranding self-hosted deployments with no mail provider on a screen no email could ever satisfy. Derive one server-side effective value (verification enabled AND deliverable) and read it from Better Auth enforcement, signup routing, and the verify page.
1 parent dd79515 commit 7ede958

9 files changed

Lines changed: 249 additions & 13 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { buildAuthCrossLink, resolvePostSignupDestination } from '@/app/(auth)/auth-redirect'
6+
7+
describe('resolvePostSignupDestination', () => {
8+
it('routes to the verify hop when verification is enforceable', () => {
9+
expect(
10+
resolvePostSignupDestination({ emailVerificationEnabled: true, redirectUrl: '' })
11+
).toEqual({ kind: 'verify' })
12+
})
13+
14+
it('keeps the verify hop owning the callback URL when verification is enforceable', () => {
15+
expect(
16+
resolvePostSignupDestination({
17+
emailVerificationEnabled: true,
18+
redirectUrl: '/invite/abc',
19+
})
20+
).toEqual({ kind: 'verify' })
21+
})
22+
23+
/**
24+
* Regression guard: signup used to push `/verify` unconditionally, stranding
25+
* self-hosted deployments with no mail provider on a screen no email can
26+
* satisfy.
27+
*/
28+
it('never routes to verify when no mail provider is configured', () => {
29+
expect(
30+
resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' })
31+
).toEqual({ kind: 'workspace' })
32+
})
33+
34+
it('preserves the callback URL when verification is not enforceable', () => {
35+
expect(
36+
resolvePostSignupDestination({
37+
emailVerificationEnabled: false,
38+
redirectUrl: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz',
39+
})
40+
).toEqual({
41+
kind: 'redirect',
42+
url: '/cli/auth?callback=http%3A%2F%2F127.0.0.1%3A9000&state=xyz',
43+
})
44+
})
45+
})
46+
47+
describe('buildAuthCrossLink', () => {
48+
it('carries the invite flow and callback URL across the login/signup hop', () => {
49+
expect(buildAuthCrossLink('/login', { callbackUrl: '/invite/abc', isInviteFlow: true })).toBe(
50+
'/login?invite_flow=true&callbackUrl=%2Finvite%2Fabc'
51+
)
52+
})
53+
54+
it('drops the query entirely when nothing needs carrying', () => {
55+
expect(buildAuthCrossLink('/signup', { callbackUrl: null, isInviteFlow: false })).toBe(
56+
'/signup'
57+
)
58+
})
59+
})

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,44 @@
55
*/
66
export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl'
77

8+
/** Route the verify hop lives at, entered only from signup. */
9+
export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true'
10+
11+
/** Default post-auth destination when no callback URL was carried in. */
12+
export const DEFAULT_POST_AUTH_ROUTE = '/workspace'
13+
14+
/**
15+
* Where a successful email signup goes next.
16+
* - `verify`: the verification hop, which owns the post-auth redirect from there
17+
* - `redirect`: the validated callback URL the visitor arrived with
18+
* - `workspace`: the default destination
19+
*/
20+
export type PostSignupDestination =
21+
| { kind: 'verify' }
22+
| { kind: 'redirect'; url: string }
23+
| { kind: 'workspace' }
24+
25+
interface PostSignupDestinationParams {
26+
/** The server-derived effective flag — verification enabled AND deliverable. */
27+
emailVerificationEnabled: boolean
28+
/** Callback URL that already passed `validateCallbackUrl`, or `''`. */
29+
redirectUrl: string
30+
}
31+
32+
/**
33+
* `/verify` is a destination only when the deployment can actually deliver the
34+
* code. A deployment with no mail provider would otherwise strand every new
35+
* account on a screen no email can ever satisfy, so signup continues straight
36+
* to the normal post-auth destination instead.
37+
*/
38+
export function resolvePostSignupDestination({
39+
emailVerificationEnabled,
40+
redirectUrl,
41+
}: PostSignupDestinationParams): PostSignupDestination {
42+
if (emailVerificationEnabled) return { kind: 'verify' }
43+
return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' }
44+
}
45+
846
interface AuthCrossLinkParams {
947
/** Validated post-auth destination to carry over, or null to drop it. */
1048
callbackUrl: string | null

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

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

@@ -24,6 +25,7 @@ export default async function SignupPage() {
2425
microsoftAvailable={microsoftAvailable}
2526
isProduction={isProduction}
2627
emailSignupEnabled={!isEmailSignupDisabled}
28+
emailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
2729
/>
2830
)
2931
}

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

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@ import { isSsoEnabled } from '@/lib/core/config/env-flags'
1111
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
1212
import { quickValidateEmail } from '@/lib/messaging/email/validation'
1313
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
14-
import { buildAuthCrossLink, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
14+
import {
15+
buildAuthCrossLink,
16+
DEFAULT_POST_AUTH_ROUTE,
17+
POST_AUTH_REDIRECT_STORAGE_KEY,
18+
resolvePostSignupDestination,
19+
VERIFY_FROM_SIGNUP_ROUTE,
20+
} from '@/app/(auth)/auth-redirect'
1521
import {
1622
AuthDivider,
1723
AuthField,
@@ -86,6 +92,8 @@ interface SignupFormProps {
8692
microsoftAvailable: boolean
8793
isProduction: boolean
8894
emailSignupEnabled: boolean
95+
/** Server-derived: verification is enabled AND a mail provider is configured. */
96+
emailVerificationEnabled: boolean
8997
}
9098

9199
function SignupFormContent({
@@ -94,6 +102,7 @@ function SignupFormContent({
94102
microsoftAvailable,
95103
isProduction,
96104
emailSignupEnabled,
105+
emailVerificationEnabled,
97106
}: SignupFormProps) {
98107
const router = useRouter()
99108
const searchParams = useSearchParams()
@@ -343,18 +352,29 @@ function SignupFormContent({
343352
logger.error('Failed to refresh session after signup:', sessionError)
344353
}
345354

355+
const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl })
356+
346357
if (typeof window !== 'undefined') {
347-
sessionStorage.setItem('verificationEmail', emailValue)
348-
if (redirectUrl) {
349-
sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
350-
} else {
351-
// Clear any leftover from an earlier signup in this tab — otherwise a
352-
// signup with no callbackUrl inherits the previous CLI/invite destination.
353-
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
358+
// Clear any leftover from an earlier signup in this tab — otherwise a
359+
// signup with no callbackUrl inherits the previous CLI/invite destination.
360+
sessionStorage.removeItem('verificationEmail')
361+
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
362+
363+
if (destination.kind === 'verify') {
364+
sessionStorage.setItem('verificationEmail', emailValue)
365+
if (redirectUrl) sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
354366
}
355367
}
356368

357-
router.push('/verify?fromSignup=true')
369+
if (destination.kind === 'verify') {
370+
router.push(VERIFY_FROM_SIGNUP_ROUTE)
371+
} else if (destination.kind === 'redirect') {
372+
// Full navigation, matching the verify hop: the destination (invite, CLI
373+
// handoff) is server-rendered and must see the fresh session cookie.
374+
window.location.href = destination.url
375+
} else {
376+
router.push(DEFAULT_POST_AUTH_ROUTE)
377+
}
358378
} catch (error) {
359379
logger.error('Signup error:', error)
360380
setIsLoading(false)
@@ -488,6 +508,7 @@ export default function SignupPage({
488508
microsoftAvailable,
489509
isProduction,
490510
emailSignupEnabled,
511+
emailVerificationEnabled,
491512
}: SignupFormProps) {
492513
return (
493514
<Suspense
@@ -499,6 +520,7 @@ export default function SignupPage({
499520
microsoftAvailable={microsoftAvailable}
500521
isProduction={isProduction}
501522
emailSignupEnabled={emailSignupEnabled}
523+
emailVerificationEnabled={emailVerificationEnabled}
502524
/>
503525
</Suspense>
504526
)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockHasEmailService, mockIsEmailVerificationEffectivelyEnabled } = vi.hoisted(() => ({
7+
mockHasEmailService: vi.fn<() => boolean>(),
8+
mockIsEmailVerificationEffectivelyEnabled: vi.fn<() => boolean>(),
9+
}))
10+
11+
vi.mock('@/lib/messaging/email/mailer', () => ({
12+
hasEmailService: mockHasEmailService,
13+
}))
14+
15+
vi.mock('@/lib/messaging/email/verification', () => ({
16+
isEmailVerificationEffectivelyEnabled: mockIsEmailVerificationEffectivelyEnabled,
17+
}))
18+
19+
vi.mock('@/app/(auth)/verify/verify-content', () => ({
20+
VerifyContent: () => null,
21+
}))
22+
23+
import VerifyPage from '@/app/(auth)/verify/page'
24+
25+
describe('verify page', () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks()
28+
})
29+
30+
it('renders the verification experience when a mail provider is configured', () => {
31+
mockHasEmailService.mockReturnValue(true)
32+
mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(true)
33+
34+
const element = VerifyPage()
35+
36+
expect(element.props.hasEmailService).toBe(true)
37+
expect(element.props.isEmailVerificationEnabled).toBe(true)
38+
})
39+
40+
/**
41+
* The page hands the effective value down, so the verification form never
42+
* renders on a deployment that cannot deliver a code — it redirects instead.
43+
*/
44+
it('reports verification off when no mail provider is configured', () => {
45+
mockHasEmailService.mockReturnValue(false)
46+
mockIsEmailVerificationEffectivelyEnabled.mockReturnValue(false)
47+
48+
const element = VerifyPage()
49+
50+
expect(element.props.hasEmailService).toBe(false)
51+
expect(element.props.isEmailVerificationEnabled).toBe(false)
52+
})
53+
})

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Metadata } from 'next'
2-
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags'
2+
import { isProd } from '@/lib/core/config/env-flags'
33
import { hasEmailService } from '@/lib/messaging/email/mailer'
4+
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
45
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
56

67
export const metadata: Metadata = {
@@ -16,7 +17,7 @@ export default function VerifyPage() {
1617
<VerifyContent
1718
hasEmailService={emailServiceConfigured}
1819
isProduction={isProd}
19-
isEmailVerificationEnabled={isEmailVerificationEnabled}
20+
isEmailVerificationEnabled={isEmailVerificationEffectivelyEnabled()}
2021
/>
2122
)
2223
}

apps/sim/lib/auth/auth.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import { sendEmail } from '@/lib/messaging/email/mailer'
9090
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
9191
import { quickValidateEmail } from '@/lib/messaging/email/validation'
9292
import { validateSignupEmailMx } from '@/lib/messaging/email/validation.server'
93+
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
9394
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
9495
import { getMicrosoftRefreshTokenExpiry, isMicrosoftProvider } from '@/lib/oauth/microsoft'
9596
import { extractSlackTeamId, fanOutSlackTokenChain } from '@/lib/oauth/slack'
@@ -792,7 +793,7 @@ export const auth = betterAuth({
792793
* can still sign in.
793794
*/
794795
disableSignUp: isEmailSignupDisabled,
795-
requireEmailVerification: isEmailVerificationEnabled,
796+
requireEmailVerification: isEmailVerificationEffectivelyEnabled(),
796797
/**
797798
* When someone signs up with an already-registered email, better-auth returns a
798799
* generic success response (OWASP enumeration protection) instead of leaking that
@@ -1459,7 +1460,7 @@ export const auth = betterAuth({
14591460
organization({
14601461
allowUserToCreateOrganization: async () => false,
14611462
disableOrganizationDeletion: true,
1462-
requireEmailVerificationOnInvitation: isEmailVerificationEnabled,
1463+
requireEmailVerificationOnInvitation: isEmailVerificationEffectivelyEnabled(),
14631464
organizationHooks: {
14641465
afterCreateOrganization: async ({ organization, user }) => {
14651466
logger.info('[organizationHooks.afterCreateOrganization] Organization created', {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
5+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockHasEmailService } = vi.hoisted(() => ({
8+
mockHasEmailService: vi.fn<() => boolean>(),
9+
}))
10+
11+
vi.mock('@/lib/messaging/email/mailer', () => ({
12+
hasEmailService: mockHasEmailService,
13+
}))
14+
15+
import { isEmailVerificationEffectivelyEnabled } from '@/lib/messaging/email/verification'
16+
17+
describe('isEmailVerificationEffectivelyEnabled', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
resetEnvFlagsMock()
21+
})
22+
23+
afterAll(resetEnvFlagsMock)
24+
25+
it('requires verification when it is enabled and a mail provider is configured', () => {
26+
setEnvFlags({ isEmailVerificationEnabled: true })
27+
mockHasEmailService.mockReturnValue(true)
28+
29+
expect(isEmailVerificationEffectivelyEnabled()).toBe(true)
30+
})
31+
32+
it('does not require verification when no mail provider is configured', () => {
33+
setEnvFlags({ isEmailVerificationEnabled: true })
34+
mockHasEmailService.mockReturnValue(false)
35+
36+
expect(isEmailVerificationEffectivelyEnabled()).toBe(false)
37+
})
38+
39+
it('does not require verification when the feature is disabled', () => {
40+
setEnvFlags({ isEmailVerificationEnabled: false })
41+
mockHasEmailService.mockReturnValue(true)
42+
43+
expect(isEmailVerificationEffectivelyEnabled()).toBe(false)
44+
})
45+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { isEmailVerificationEnabled } from '@/lib/core/config/env-flags'
2+
import { hasEmailService } from '@/lib/messaging/email/mailer'
3+
4+
/**
5+
* Whether email verification is actually enforceable on this deployment.
6+
*
7+
* `EMAIL_VERIFICATION_ENABLED` alone only says the operator wants verification;
8+
* without a configured mail provider no code can ever be delivered, so
9+
* enforcing it locks every new account out behind a screen it cannot satisfy.
10+
* This is the single server-derived value Better Auth enforcement, signup
11+
* routing, and the verify page all read, so they cannot disagree.
12+
*/
13+
export function isEmailVerificationEffectivelyEnabled(): boolean {
14+
return isEmailVerificationEnabled && hasEmailService()
15+
}

0 commit comments

Comments
 (0)