Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/__test__/integration/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import {
forgotPasswordAction,
resendSignupVerificationAction,
signInAction,
signInWithOAuthAction,
signOutAction,
Expand All @@ -20,6 +21,11 @@ const { verifyTurnstileToken } = vi.hoisted(() => ({
verifyTurnstileToken: vi.fn(),
}))

const { kvGetMock, kvSetMock } = vi.hoisted(() => ({
kvGetMock: vi.fn(),
kvSetMock: vi.fn(),
}))

// Mock console.error to prevent output during tests
const originalConsoleError = console.error
console.error = vi.fn()
Expand All @@ -36,6 +42,7 @@ const mockSupabaseClient = {
auth: {
signInWithPassword: vi.fn(),
signUp: vi.fn(),
resend: vi.fn(),
resetPasswordForEmail: vi.fn(),
updateUser: vi.fn(),
signInWithOAuth: vi.fn(),
Expand Down Expand Up @@ -86,6 +93,13 @@ vi.mock('@/lib/captcha/turnstile', () => ({
verifyTurnstileToken,
}))

vi.mock('@/core/shared/clients/kv', () => ({
kv: {
get: kvGetMock,
set: kvSetMock,
},
}))

describe('Auth Actions - Integration Tests', () => {
beforeEach(() => {
vi.resetAllMocks()
Expand All @@ -96,6 +110,8 @@ describe('Auth Actions - Integration Tests', () => {
})
global.fetch = fetchMock as unknown as typeof fetch
verifyTurnstileToken.mockResolvedValue(true)
kvGetMock.mockResolvedValue(null)
kvSetMock.mockResolvedValue('OK')
})

afterEach(() => {
Expand Down Expand Up @@ -383,6 +399,79 @@ describe('Auth Actions - Integration Tests', () => {
}) */
})

describe('Resend Signup Verification Flow', () => {
it('should resend signup verification with callback returnTo', async () => {
mockSupabaseClient.auth.resend.mockResolvedValue({
data: {},
error: null,
})

const result = await resendSignupVerificationAction({
email: 'NewUser@Example.com',
returnTo: '/dashboard/team-123/sandboxes',
})

expect(result).toBeDefined()
expect(result).not.toHaveProperty('serverError')
expect(result).not.toHaveProperty('validationErrors')
expect(mockSupabaseClient.auth.resend).toHaveBeenCalledWith({
type: 'signup',
email: 'newuser@example.com',
options: {
emailRedirectTo:
'https://app.e2b.dev/api/auth/callback?returnTo=%2Fdashboard%2Fteam-123%2Fsandboxes',
},
})
expect(kvGetMock).toHaveBeenCalledTimes(1)
expect(kvSetMock).toHaveBeenCalledWith(
expect.stringContaining('auth:resend-signup-verification:'),
true,
{
ex: 60,
}
)
})

it('should short-circuit resend when cooldown key exists', async () => {
kvGetMock.mockResolvedValue(true)

const result = await resendSignupVerificationAction({
email: 'user@example.com',
})

expect(result).toBeDefined()
expect(result).not.toHaveProperty('serverError')
expect(result).not.toHaveProperty('validationErrors')
expect(mockSupabaseClient.auth.resend).not.toHaveBeenCalled()
expect(kvSetMock).not.toHaveBeenCalled()
})

it('should not fail when provider returns resend error', async () => {
mockSupabaseClient.auth.resend.mockResolvedValue({
data: {},
error: { message: 'security purposes' },
})

const result = await resendSignupVerificationAction({
email: 'user@example.com',
})

expect(result).toBeDefined()
expect(result).not.toHaveProperty('serverError')
expect(result).not.toHaveProperty('validationErrors')
expect(kvSetMock).toHaveBeenCalledTimes(1)
})

it('should return validation errors when email is invalid', async () => {
const result = await resendSignupVerificationAction({
email: 'invalid-email',
})

expect(result).toBeDefined()
expect(result).toHaveProperty('validationErrors')
})
})

describe('OAuth Authentication', () => {
/**
* AUTHENTICATION TEST: Verifies that OAuth sign-in redirects to provider
Expand Down
19 changes: 19 additions & 0 deletions src/app/(auth)/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { signInAction } from '@/core/server/actions/auth-actions'
import { signInSchema } from '@/core/server/functions/auth/auth.types'
import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message'
import { OAuthProviders } from '@/features/auth/oauth-provider-buttons'
import { ResendVerificationForm } from '@/features/auth/resend-verification'
import { Button } from '@/ui/primitives/button'
import {
Form,
Expand Down Expand Up @@ -58,6 +59,18 @@ export default function Login() {
})

const returnTo = searchParams.get('returnTo') || undefined
const decodedQueryError = decodeURIComponent(searchParams.get('error') || '')
const hasUnconfirmedEmailMessage =
!!message &&
'success' in message &&
message.success === USER_MESSAGES.signInEmailNotConfirmed.message
const hasVerificationLinkError =
decodedQueryError.toLowerCase().includes('verification link') ||
decodedQueryError.toLowerCase().includes('email link has expired')
const shouldShowResendVerification =
hasUnconfirmedEmailMessage || hasVerificationLinkError
const resendInitialEmail =
form.watch('email') || searchParams.get('email') || ''

useEffect(() => {
form.setValue('returnTo', returnTo)
Expand Down Expand Up @@ -167,6 +180,12 @@ export default function Login() {
</p>

{message && <AuthFormMessage className="mt-4" message={message} />}
{shouldShowResendVerification && (
<ResendVerificationForm
initialEmail={resendInitialEmail}
returnTo={returnTo}
/>
)}
</div>
)
}
13 changes: 13 additions & 0 deletions src/app/(auth)/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { signUpAction } from '@/core/server/actions/auth-actions'
import { signUpSchema } from '@/core/server/functions/auth/auth.types'
import { AuthFormMessage, type AuthMessage } from '@/features/auth/form-message'
import { OAuthProviders } from '@/features/auth/oauth-provider-buttons'
import { ResendVerificationForm } from '@/features/auth/resend-verification'
import { TurnstileWidget } from '@/features/auth/turnstile-widget'
import { useTurnstile } from '@/features/auth/use-turnstile'
import { Button } from '@/ui/primitives/button'
Expand Down Expand Up @@ -68,6 +69,12 @@ export default function SignUp() {

const turnstile = useTurnstile(form)
turnstileResetRef.current = turnstile.reset
const shouldShowResendVerification =
!!message &&
'success' in message &&
message.success === USER_MESSAGES.signUpVerification.message
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
const resendInitialEmail =
form.watch('email') || searchParams.get('email') || ''

useEffect(() => {
form.setValue('returnTo', returnTo)
Expand Down Expand Up @@ -209,6 +216,12 @@ export default function SignUp() {
</p>

{message && <AuthFormMessage className="mt-4" message={message} />}
{shouldShowResendVerification && (
<ResendVerificationForm
initialEmail={resendInitialEmail}
returnTo={returnTo}
/>
)}
</div>
)
}
4 changes: 4 additions & 0 deletions src/configs/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export const KV_KEYS = {
TEAM_SLUG_TO_ID: (slug: string) => `team-slug:${slug}:id`,
TEAM_ID_TO_SLUG: (teamId: string) => `team-id:${teamId}:slug`,
WARNED_ALTERNATE_EMAIL: (email: string) => `warned-alternate-email:${email}`,
AUTH_RESEND_SIGNUP_VERIFICATION_COOLDOWN: (
emailHash: string,
requesterHash: string
) => `auth:resend-signup-verification:${emailHash}:${requesterHash}`,
}

/*
Expand Down
5 changes: 5 additions & 0 deletions src/configs/user-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export const USER_MESSAGES = {
message: 'Check your e-mail for a verification link.',
timeoutMs: 30000,
},
signUpVerificationResend: {
message:
'If an account exists and is awaiting confirmation, we sent a new verification link.',
timeoutMs: 30000,
},
passwordReset: {
message: 'Check your e-mail for a reset link.',
timeoutMs: 30000,
Expand Down
104 changes: 104 additions & 0 deletions src/core/server/actions/auth-actions.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
'use server'

import { createHash } from 'node:crypto'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { returnValidationErrors } from 'next-safe-action'
import { z } from 'zod'
import { CAPTCHA_REQUIRED_SERVER } from '@/configs/flags'
import { KV_KEYS } from '@/configs/keys'
import { AUTH_URLS, PROTECTED_URLS } from '@/configs/urls'
import { USER_MESSAGES } from '@/configs/user-messages'
import { actionClient } from '@/core/server/actions/client'
import { returnServerError } from '@/core/server/actions/utils'
import {
forgotPasswordSchema,
resendSignupVerificationSchema,
signInSchema,
signUpSchema,
} from '@/core/server/functions/auth/auth.types'
import {
shouldWarnAboutAlternateEmail,
validateEmail,
} from '@/core/server/functions/auth/validate-email'
import { kv } from '@/core/shared/clients/kv'
import { l } from '@/core/shared/clients/logger/logger'
import { supabaseAdmin } from '@/core/shared/clients/supabase/admin'
import { createClient } from '@/core/shared/clients/supabase/server'
Expand Down Expand Up @@ -63,6 +67,15 @@ async function checkAuthProviderHealth(): Promise<boolean> {

const AUTH_PROVIDER_ERROR_MESSAGE =
'Our authentication provider is experiencing issues. Please try again later.'
const RESEND_SIGNUP_VERIFICATION_COOLDOWN_SECONDS = 60
const RESEND_SIGNUP_VERIFICATION_HASH_PREFIX_LENGTH = 24

function hashCooldownPart(value: string): string {
return createHash('sha256')
.update(value)
.digest('hex')
.slice(0, RESEND_SIGNUP_VERIFICATION_HASH_PREFIX_LENGTH)
}

const SignInWithOAuthInputSchema = z.object({
provider: z.union([z.literal('github'), z.literal('google')]),
Expand Down Expand Up @@ -331,6 +344,97 @@ export const forgotPasswordAction = actionClient
}
})

export const resendSignupVerificationAction = actionClient
.schema(resendSignupVerificationSchema)
.metadata({ actionName: 'resendSignupVerification' })
.action(async ({ parsedInput: { email, returnTo = '' } }) => {
const headerStore = await headers()
const origin = headerStore.get('origin')

if (!origin) {
throw new Error('Origin not found')
}

const normalizedEmail = email.trim().toLowerCase()
const requesterIp =
headerStore.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown-ip'
const requesterUserAgent = headerStore.get('user-agent') ?? 'unknown-agent'

const emailHash = hashCooldownPart(normalizedEmail)
const requesterHash = hashCooldownPart(
`${requesterIp}:${requesterUserAgent}`
)
const cooldownKey = KV_KEYS.AUTH_RESEND_SIGNUP_VERIFICATION_COOLDOWN(
emailHash,
requesterHash
)

try {
const cooldownKeyExists = await kv.get<boolean>(cooldownKey)

if (cooldownKeyExists) {
return
}

await kv.set(cooldownKey, true, {
ex: RESEND_SIGNUP_VERIFICATION_COOLDOWN_SECONDS,
})
} catch (kvError) {
l.warn(
{
key: 'resend_signup_verification_action:kv_error',
error: kvError,
context: {
email_hash: emailHash,
requester_hash: requesterHash,
},
},
'failed to access resend verification cooldown key'
)
}

const callbackUrl = `${origin}${AUTH_URLS.CALLBACK}${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`

try {
const supabase = await createClient()
const { error } = await supabase.auth.resend({
type: 'signup',
email: normalizedEmail,
options: {
emailRedirectTo: callbackUrl,
},
})

if (error) {
l.warn(
{
key: 'resend_signup_verification_action:supabase_error',
error,
context: {
email_hash: emailHash,
requester_hash: requesterHash,
has_return_to: returnTo.length > 0,
},
},
`failed to resend signup verification email: ${error.message}`
)
}
} catch (error) {
l.warn(
{
key: 'resend_signup_verification_action:unexpected_error',
error,
context: {
email_hash: emailHash,
requester_hash: requesterHash,
has_return_to: returnTo.length > 0,
},
},
'unexpected error while resending signup verification email'
)
}
})

export async function signOutAction(returnTo?: string) {
const supabase = await createClient()

Expand Down
6 changes: 6 additions & 0 deletions src/core/server/functions/auth/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ export const forgotPasswordSchema = z.object({
email: emailSchema,
callbackUrl: z.string().optional(),
})

export const resendSignupVerificationSchema = z.object({
email: emailSchema,
returnTo: relativeUrlSchema.optional(),
captchaToken: z.string().optional(),
})
3 changes: 3 additions & 0 deletions src/features/auth/resend-verification/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const RESEND_VERIFICATION_COOLDOWN_SECONDS = 60
export const RESEND_VERIFICATION_BUTTON_LABEL = 'Resend verification e-mail'
export const RESEND_VERIFICATION_LOADING_LABEL = 'Sending...'
Loading
Loading