From 96af8b8ef330811cd8ddbd3a656751a475f620d6 Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 12:43:31 +0200 Subject: [PATCH 01/11] fix(auth): use token hash flow for email confirmations Add a dedicated /auth/confirm route that verifies Supabase email auth links with token_hash and verifyOtp, avoiding PKCE code-verifier failures when users open email links from another browser, device, or email client webview. Keep /auth/callback for OAuth code exchanges, share safe redirect handling between auth routes, and update signup/reset-password redirects to pass final same-origin destinations for Supabase email templates. Document the required Supabase dashboard email template changes and add Vitest and Playwright coverage for the confirmation route, email signup redirect target, malformed confirmation links, unsafe redirects, and verifyOtp failures. --- .../app/[lang]/(auth)/signup/signup-form.tsx | 2 +- apps/web/app/actions.ts | 10 +- apps/web/app/auth/callback/route.ts | 48 ++--- apps/web/app/auth/confirm/route.ts | 92 ++++++++++ apps/web/e2e/E2E_TEST_PLAN.md | 7 +- apps/web/e2e/auth-email.spec.ts | 113 ++++++++++++ apps/web/lib/supabase/auth-redirect.ts | 62 +++++++ apps/web/lib/supabase/middleware.ts | 1 + apps/web/proxy.ts | 1 + apps/web/tests/auth-confirm.test.ts | 170 ++++++++++++++++++ docs/devops.md | 32 ++++ 11 files changed, 496 insertions(+), 42 deletions(-) create mode 100644 apps/web/app/auth/confirm/route.ts create mode 100644 apps/web/e2e/auth-email.spec.ts create mode 100644 apps/web/lib/supabase/auth-redirect.ts create mode 100644 apps/web/tests/auth-confirm.test.ts diff --git a/apps/web/app/[lang]/(auth)/signup/signup-form.tsx b/apps/web/app/[lang]/(auth)/signup/signup-form.tsx index 017690511..a4f918106 100644 --- a/apps/web/app/[lang]/(auth)/signup/signup-form.tsx +++ b/apps/web/app/[lang]/(auth)/signup/signup-form.tsx @@ -51,7 +51,7 @@ export function SignUpForm({ email, password, options: { - emailRedirectTo: `${window.location.origin}/auth/callback`, + emailRedirectTo: `${window.location.origin}/${lang}/dashboard`, // data: { // username, // }, diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts index b8acb6cd4..be2811210 100644 --- a/apps/web/app/actions.ts +++ b/apps/web/app/actions.ts @@ -30,11 +30,17 @@ export const forgotPasswordAction = async (formData: FormData) => { } const supabase = await createClient(); - const origin = (await headers()).get('origin'); + const origin = + (await headers()).get('origin') ?? process.env.NEXT_PUBLIC_SITE_URL; const callbackUrl = formData.get('callbackUrl')?.toString(); + const updatePasswordPath = `/${lang}/protected/update-password?email=${encodeURIComponent(email)}`; + + if (!origin) { + return encodedRedirect('error', `/${lang}/reset-password`, 'generic_error'); + } const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: `${origin}/auth/callback?redirect_to=/${lang}/protected/update-password&email=${encodeURIComponent(email)}`, + redirectTo: new URL(updatePasswordPath, origin).toString(), }); if (error) { diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index 3085cf286..8f38f6dd0 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -4,25 +4,15 @@ import { NextResponse } from 'next/server'; import PostHogClient from '@/lib/posthog'; import { createOrRetrieveCustomer } from '@/lib/stripe/stripe-admin'; -import { OAUTH_CALLBACK_COOKIE_NAME } from '@/lib/supabase/constants'; import { - createOauthCallbackMarkerValue, - OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, -} from '@/lib/supabase/oauth-callback-marker'; + createAuthRedirectResponse, + getLocaleFromRedirectPath, + getSafeAuthRedirectPath, +} from '@/lib/supabase/auth-redirect'; +import { OAUTH_CALLBACK_COOKIE_NAME } from '@/lib/supabase/constants'; import { createClient } from '@/lib/supabase/server'; import { routing } from '@/src/i18n/routing'; -const isSafeRedirectPath = (value: string | null) => - Boolean(value?.startsWith('/') && !value.startsWith('//')); - -const getLocaleFromRedirectPath = (redirectPath: string | null) => { - const locale = redirectPath?.split('/')[1]; - - return routing.locales.includes(locale as (typeof routing.locales)[number]) - ? locale - : routing.defaultLocale; -}; - const getOauthCodeFingerprint = (code: string | null) => { if (!code) { return { @@ -102,25 +92,6 @@ const getOauthCallbackCookieContext = (request: Request) => { }; }; -const createOauthRedirectResponse = (url: string) => { - const response = NextResponse.redirect(url); - const markerValue = createOauthCallbackMarkerValue(); - - if (markerValue) { - response.cookies.set({ - name: OAUTH_CALLBACK_COOKIE_NAME, - value: markerValue, - httpOnly: true, - maxAge: OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, - path: '/', - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - }); - } - - return response; -}; - export async function GET(request: Request) { // The `/auth/callback` route is required for the server-side auth flow implemented // by the SSR package. It exchanges an auth code for the user's session. @@ -129,7 +100,8 @@ export async function GET(request: Request) { const code = requestUrl.searchParams.get('code'); const origin = requestUrl.origin; const redirectTo = requestUrl.searchParams.get('redirect_to'); - const locale = getLocaleFromRedirectPath(redirectTo); + const safeRedirectPath = getSafeAuthRedirectPath(redirectTo, origin); + const locale = getLocaleFromRedirectPath(safeRedirectPath); const loginPath = `/${locale}/login`; const oauthCodeContext = getOauthCodeFingerprint(code); const oauthCookieContext = getOauthCallbackCookieContext(request); @@ -229,12 +201,12 @@ export async function GET(request: Request) { await posthog.shutdown(); } - if (isSafeRedirectPath(redirectTo)) { - return createOauthRedirectResponse(`${origin}${redirectTo}`); + if (safeRedirectPath) { + return createAuthRedirectResponse(`${origin}${safeRedirectPath}`); } // URL to redirect to after sign up process completes - return createOauthRedirectResponse( + return createAuthRedirectResponse( `${origin}/${routing.defaultLocale}/dashboard`, ); } catch (error) { diff --git a/apps/web/app/auth/confirm/route.ts b/apps/web/app/auth/confirm/route.ts new file mode 100644 index 000000000..adc2999de --- /dev/null +++ b/apps/web/app/auth/confirm/route.ts @@ -0,0 +1,92 @@ +import { captureException, captureMessage } from '@sentry/nextjs'; +import type { EmailOtpType } from '@supabase/supabase-js'; +import { NextResponse } from 'next/server'; + +import { + createAuthRedirectResponse, + getLocaleFromRedirectPath, + getSafeAuthRedirectPath, +} from '@/lib/supabase/auth-redirect'; +import { createClient } from '@/lib/supabase/server'; + +const EMAIL_OTP_TYPES = new Set([ + 'signup', + 'invite', + 'magiclink', + 'recovery', + 'email_change', + 'email', +]); + +const isEmailOtpType = (value: string | null): value is EmailOtpType => + Boolean(value && EMAIL_OTP_TYPES.has(value as EmailOtpType)); + +const getDefaultSuccessPath = (type: EmailOtpType, locale: string) => + type === 'recovery' + ? `/${locale}/protected/update-password` + : `/${locale}/dashboard`; + +export async function GET(request: Request) { + // Email auth links use token_hash + verifyOtp so confirmation and recovery + // links do not depend on a browser-local PKCE code verifier. + // https://supabase.com/docs/guides/auth/auth-email-templates#redirecting-the-user-to-a-server-side-endpoint + const requestUrl = new URL(request.url); + const origin = requestUrl.origin; + const tokenHash = requestUrl.searchParams.get('token_hash'); + const type = requestUrl.searchParams.get('type'); + const redirectTo = + requestUrl.searchParams.get('redirect_to') ?? + requestUrl.searchParams.get('next'); + const safeRedirectPath = getSafeAuthRedirectPath(redirectTo, origin); + const locale = getLocaleFromRedirectPath(safeRedirectPath); + const loginPath = `/${locale}/login`; + + if (!(tokenHash && isEmailOtpType(type))) { + captureMessage( + 'Email auth confirmation link missing required parameters.', + { + level: 'warning', + tags: { + area: 'auth', + flow: 'email-auth-confirm', + error_type: 'missing-confirmation-params', + }, + extra: { + hasTokenHash: Boolean(tokenHash), + type, + redirectTo, + safeRedirectPath, + }, + }, + ); + + return NextResponse.redirect(`${origin}${loginPath}`); + } + + const supabase = await createClient(); + const { error } = await supabase.auth.verifyOtp({ + token_hash: tokenHash, + type, + }); + + if (error) { + captureException(error, { + tags: { + area: 'auth', + flow: 'email-auth-confirm', + error_type: 'verify-otp-failed', + }, + extra: { + type, + redirectTo, + safeRedirectPath, + }, + }); + + return NextResponse.redirect(`${origin}${loginPath}`); + } + + return createAuthRedirectResponse( + `${origin}${safeRedirectPath ?? getDefaultSuccessPath(type, locale)}`, + ); +} diff --git a/apps/web/e2e/E2E_TEST_PLAN.md b/apps/web/e2e/E2E_TEST_PLAN.md index 5ae3f442c..8e83dcb31 100644 --- a/apps/web/e2e/E2E_TEST_PLAN.md +++ b/apps/web/e2e/E2E_TEST_PLAN.md @@ -3,7 +3,7 @@ > **Status**: ✅ Implemented > **Last Updated**: 2026-05-10 > **Scope**: `apps/web/e2e/*` -> **Current Result**: 97 tests total — 96 passing, 1 intentionally skipped +> **Current Result**: 99 tests total — 98 passing, 1 intentionally skipped --- @@ -41,6 +41,7 @@ The current suite covers the main dashboard surfaces with a mix of: | History | `/en/dashboard/history` | `history-dashboard.spec.ts` | `pages/history.page.ts` | none currently required | 12 | | Usage | `/en/dashboard/usage` | `usage-dashboard.spec.ts` | `pages/usage.page.ts` | `mocks/usage.mock.ts` | 14 | | Profile | `/en/dashboard/profile` | `profile-dashboard.spec.ts` | `pages/profile.page.ts` | one narrow auth-route passthrough in mismatch test | 13 | +| Email auth | `/en/signup`, `/auth/confirm` | `auth-email.spec.ts` | none | mocked browser-side Supabase signup request | 2 | --- @@ -56,6 +57,9 @@ Implemented behavior: - saves auth state to `.auth/user.json` - all authenticated specs depend on this setup project - unauthenticated specs override `storageState` with empty cookies/origins +- server-side `/auth/confirm` token verification remains covered by Vitest route + tests because Playwright route mocks cannot intercept Next server outbound + fetches ### Page Object Models @@ -419,6 +423,7 @@ apps/web/e2e/ │ ├── history.page.ts │ ├── profile.page.ts │ └── usage.page.ts +├── auth-email.spec.ts ├── call-dashboard.spec.ts ├── clone-dashboard.spec.ts ├── credits-dashboard.spec.ts diff --git a/apps/web/e2e/auth-email.spec.ts b/apps/web/e2e/auth-email.spec.ts new file mode 100644 index 000000000..026b7620b --- /dev/null +++ b/apps/web/e2e/auth-email.spec.ts @@ -0,0 +1,113 @@ +import { expect, type Route, test } from '@playwright/test'; + +test.use({ storageState: { cookies: [], origins: [] } }); + +const corsHeaders = (origin: string) => ({ + 'access-control-allow-headers': + 'authorization, apikey, content-type, x-client-info, x-supabase-api-version', + 'access-control-allow-methods': 'POST, OPTIONS', + 'access-control-allow-origin': origin, +}); + +const mockSignupUser = (email: string) => { + const now = new Date().toISOString(); + + return { + id: '11111111-1111-4111-8111-111111111111', + aud: 'authenticated', + role: 'authenticated', + email, + phone: '', + app_metadata: { + provider: 'email', + providers: ['email'], + }, + user_metadata: {}, + identities: [ + { + id: '11111111-1111-4111-8111-111111111111', + user_id: '11111111-1111-4111-8111-111111111111', + identity_id: '22222222-2222-4222-8222-222222222222', + provider: 'email', + email, + identity_data: { + email, + email_verified: false, + phone_verified: false, + sub: '11111111-1111-4111-8111-111111111111', + }, + last_sign_in_at: now, + created_at: now, + updated_at: now, + }, + ], + created_at: now, + updated_at: now, + }; +}; + +test.describe('Email auth links', () => { + test.afterEach(async ({ page }) => { + await page.unroute('**/*'); + }); + + test('email signup sends a final destination instead of /auth/callback', async ({ + page, + }) => { + const email = `auth-email-${Date.now()}@example.com`; + let signupRedirectTo: string | null = null; + let signupBody: Record | null = null; + + await page.route('**/auth/v1/signup**', async (route: Route) => { + const request = route.request(); + const origin = new URL(page.url()).origin; + + if (request.method() === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: corsHeaders(origin), + }); + return; + } + + const requestUrl = new URL(request.url()); + signupRedirectTo = requestUrl.searchParams.get('redirect_to'); + signupBody = request.postDataJSON(); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: corsHeaders(origin), + body: JSON.stringify(mockSignupUser(email)), + }); + }); + + await page.goto('/en/signup'); + const appOrigin = new URL(page.url()).origin; + + await page.getByLabel('Email address').fill(email); + await page.getByLabel('Password').fill('Playwright-password-123'); + await page.getByRole('button', { name: 'Sign up', exact: true }).click(); + + await expect.poll(() => signupRedirectTo).toBe(`${appOrigin}/en/dashboard`); + expect(signupRedirectTo).not.toContain('/auth/callback'); + expect(signupBody).toMatchObject({ + email, + password: 'Playwright-password-123', + }); + await expect( + page.getByText('Check your email inbox for verification'), + ).toBeVisible(); + }); + + test('malformed confirmation links return users to login', async ({ + page, + }) => { + await page.goto('/auth/confirm?type=email'); + + await expect(page).toHaveURL(/\/en\/login$/); + await expect( + page.getByRole('heading', { name: /welcome back/i }), + ).toBeVisible(); + }); +}); diff --git a/apps/web/lib/supabase/auth-redirect.ts b/apps/web/lib/supabase/auth-redirect.ts new file mode 100644 index 000000000..3a079847d --- /dev/null +++ b/apps/web/lib/supabase/auth-redirect.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server'; + +import { i18n, type Locale } from '@/lib/i18n/i18n-config'; +import { OAUTH_CALLBACK_COOKIE_NAME } from './constants'; +import { + createOauthCallbackMarkerValue, + OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, +} from './oauth-callback-marker'; + +export const getSafeAuthRedirectPath = ( + value: string | null, + origin: string, +) => { + const redirectValue = value?.trim(); + + if (!redirectValue || redirectValue.startsWith('//')) { + return null; + } + + try { + const redirectUrl = redirectValue.startsWith('/') + ? new URL(redirectValue, origin) + : new URL(redirectValue); + + if (redirectUrl.origin !== origin) { + return null; + } + + return `${redirectUrl.pathname}${redirectUrl.search}`; + } catch { + return null; + } +}; + +export const getLocaleFromRedirectPath = ( + redirectPath: string | null, +): Locale => { + const locale = redirectPath?.split('/')[1]; + + return i18n.locales.includes(locale as Locale) + ? (locale as Locale) + : i18n.defaultLocale; +}; + +export const createAuthRedirectResponse = (url: string) => { + const response = NextResponse.redirect(url); + const markerValue = createOauthCallbackMarkerValue(); + + if (markerValue) { + response.cookies.set({ + name: OAUTH_CALLBACK_COOKIE_NAME, + value: markerValue, + httpOnly: true, + maxAge: OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, + path: '/', + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + }); + } + + return response; +}; diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts index a7c0de50e..b4c8d5c9f 100644 --- a/apps/web/lib/supabase/middleware.ts +++ b/apps/web/lib/supabase/middleware.ts @@ -30,6 +30,7 @@ const clearOauthCallbackCookie = (response: NextResponse) => { const publicRoutes = [ '/api/health', '/auth/callback', + '/auth/confirm', ...routesPerLocale([ '/', '/signup', diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index cdcd63d15..acc3359be 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -89,6 +89,7 @@ export async function proxy(request: NextRequest) { if ( pathname === '/auth/callback' || + pathname === '/auth/confirm' || pathname.startsWith('/markdown-internal/') ) { return NextResponse.next(); diff --git a/apps/web/tests/auth-confirm.test.ts b/apps/web/tests/auth-confirm.test.ts new file mode 100644 index 000000000..fc425dba5 --- /dev/null +++ b/apps/web/tests/auth-confirm.test.ts @@ -0,0 +1,170 @@ +import { captureException, captureMessage } from '@sentry/nextjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { GET } from '@/app/auth/confirm/route'; +import { createClient } from '@/lib/supabase/server'; + +vi.mock('next/server', () => ({ + NextResponse: { + redirect: (url: string | URL, init?: ResponseInit | number) => { + const responseInit = typeof init === 'object' ? init : undefined; + const response = new Response(null, { + ...responseInit, + status: typeof init === 'number' ? init : (responseInit?.status ?? 307), + headers: { + location: String(url), + }, + }); + + return Object.assign(response, { + cookies: { + set: vi.fn( + ({ + maxAge, + name, + path, + value, + }: { + maxAge: number; + name: string; + path: string; + value: string; + }) => { + response.headers.append( + 'set-cookie', + `${name}=${value}; Max-Age=${maxAge}; Path=${path}`, + ); + }, + ), + }, + }); + }, + }, +})); + +describe('Email auth confirm route', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('verifies token_hash links and redirects to a same-origin destination', async () => { + const verifyOtp = vi.fn().mockResolvedValue({ + data: { user: { id: 'user-id', email: 'user@example.com' } }, + error: null, + }); + + vi.mocked(createClient).mockResolvedValueOnce({ + auth: { verifyOtp }, + } as unknown as Awaited>); + + const response = await GET( + new Request( + 'https://sexyvoice.ai/auth/confirm?token_hash=pkce_hash&type=email&redirect_to=https%3A%2F%2Fsexyvoice.ai%2Fes%2Fdashboard%3Fsource%3Demail', + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://sexyvoice.ai/es/dashboard?source=email', + ); + expect(response.headers.get('set-cookie')).toContain( + 'sv_oauth_callback_ok=', + ); + expect(verifyOtp).toHaveBeenCalledWith({ + token_hash: 'pkce_hash', + type: 'email', + }); + expect(captureException).not.toHaveBeenCalled(); + expect(captureMessage).not.toHaveBeenCalled(); + }); + + it('falls back to the default dashboard for unsafe redirect destinations', async () => { + const verifyOtp = vi.fn().mockResolvedValue({ + data: { user: { id: 'user-id', email: 'user@example.com' } }, + error: null, + }); + + vi.mocked(createClient).mockResolvedValueOnce({ + auth: { verifyOtp }, + } as unknown as Awaited>); + + const response = await GET( + new Request( + 'https://sexyvoice.ai/auth/confirm?token_hash=pkce_hash&type=email&redirect_to=https%3A%2F%2Fevil.example%2Fdashboard', + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://sexyvoice.ai/en/dashboard', + ); + expect(verifyOtp).toHaveBeenCalledWith({ + token_hash: 'pkce_hash', + type: 'email', + }); + }); + + it('reports malformed confirmation links without calling Supabase', async () => { + const response = await GET( + new Request('https://sexyvoice.ai/auth/confirm?type=email'), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://sexyvoice.ai/en/login', + ); + expect(createClient).not.toHaveBeenCalled(); + expect(captureMessage).toHaveBeenCalledWith( + 'Email auth confirmation link missing required parameters.', + expect.objectContaining({ + level: 'warning', + tags: { + area: 'auth', + flow: 'email-auth-confirm', + error_type: 'missing-confirmation-params', + }, + extra: expect.objectContaining({ + hasTokenHash: false, + type: 'email', + }), + }), + ); + }); + + it('reports verifyOtp failures and returns users to localized login', async () => { + const verifyError = new Error('Token has expired or is invalid'); + const verifyOtp = vi.fn().mockResolvedValue({ + data: { user: null }, + error: verifyError, + }); + + vi.mocked(createClient).mockResolvedValueOnce({ + auth: { verifyOtp }, + } as unknown as Awaited>); + + const response = await GET( + new Request( + 'https://sexyvoice.ai/auth/confirm?token_hash=expired&type=recovery&redirect_to=%2Ffr%2Fprotected%2Fupdate-password', + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://sexyvoice.ai/fr/login', + ); + expect(captureException).toHaveBeenCalledWith( + verifyError, + expect.objectContaining({ + tags: { + area: 'auth', + flow: 'email-auth-confirm', + error_type: 'verify-otp-failed', + }, + extra: expect.objectContaining({ + type: 'recovery', + safeRedirectPath: '/fr/protected/update-password', + }), + }), + ); + }); +}); diff --git a/docs/devops.md b/docs/devops.md index 8ace2260f..fb67a90ee 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -216,6 +216,36 @@ Generate secure secrets with: openssl rand -hex 32 ``` +### Supabase Auth email templates + +SSR auth uses `/auth/callback` for OAuth code exchanges. Email confirmation and +password recovery links should use `/auth/confirm` with `token_hash`, so users +can open links from email clients, webviews, or another browser without needing +the original PKCE code verifier in local browser storage. + +Configure the Supabase dashboard email templates like this: + +Confirm signup: + +```html + + Confirm your email + +``` + +Reset password: + +```html + + Reset your password + +``` + +Do not route email templates through `/auth/callback` and do not rely on +`{{ .ConfirmationURL }}` for SSR email auth links. The app passes final +same-origin destinations through Supabase `emailRedirectTo` / `redirectTo`, and +`/auth/confirm` validates the destination before redirecting. + ### Stripe - `STRIPE_SECRET_KEY` @@ -457,6 +487,8 @@ Check: - `OAUTH_CALLBACK_MARKER_SECRET` - redirect URL configuration in Supabase / OAuth provider - Sentry events tagged for OAuth callback flow +- Supabase email templates use `/auth/confirm?token_hash={{ .TokenHash }}` for + email confirmation and password recovery, not `/auth/callback` ### LiveKit call issues From 0c8dd7536dc13f11339402448dd8f4b29cc2940b Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:13:17 +0200 Subject: [PATCH 02/11] fix(auth): run signup side effects after email confirm --- apps/web/app/auth/callback/route.ts | 36 +++------------- apps/web/app/auth/confirm/route.ts | 30 +++++++++++++- apps/web/lib/auth/signup-side-effects.ts | 53 ++++++++++++++++++++++++ apps/web/tests/auth-confirm.test.ts | 9 ++++ 4 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 apps/web/lib/auth/signup-side-effects.ts diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index 8f38f6dd0..abecbae74 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -2,8 +2,7 @@ import { createHash } from 'node:crypto'; import { captureException, captureMessage } from '@sentry/nextjs'; import { NextResponse } from 'next/server'; -import PostHogClient from '@/lib/posthog'; -import { createOrRetrieveCustomer } from '@/lib/stripe/stripe-admin'; +import { recordSignupSideEffects } from '@/lib/auth/signup-side-effects'; import { createAuthRedirectResponse, getLocaleFromRedirectPath, @@ -156,8 +155,7 @@ export async function GET(request: Request) { return NextResponse.redirect(`${origin}${loginPath}`); } - const email = user?.email; - if (!email) { + if (!user?.email) { captureMessage('OAuth callback completed without a user email.', { level: 'error', tags: { @@ -174,32 +172,10 @@ export async function GET(request: Request) { return NextResponse.redirect(`${origin}${loginPath}`); } - // Add Stripe customer creation - if (user) { - const stripe_id = await createOrRetrieveCustomer(user.id, user.email!); - if (!stripe_id) { - console.error('Failed to create Stripe customer.'); - captureMessage('Failed to create Stripe customer.', { - level: 'error', - user: { id: user.id, email: user.email }, - }); - } - - const posthog = PostHogClient(); - - const login_type = - user.app_metadata.provider === 'email' ? 'email' : 'social'; - - posthog.capture({ - distinctId: user.id, - event: 'sign-up', - properties: { - login_type, - // is_free_trial: true, - }, - }); - await posthog.shutdown(); - } + await recordSignupSideEffects( + user, + user.app_metadata.provider === 'email' ? 'email' : 'social', + ); if (safeRedirectPath) { return createAuthRedirectResponse(`${origin}${safeRedirectPath}`); diff --git a/apps/web/app/auth/confirm/route.ts b/apps/web/app/auth/confirm/route.ts index adc2999de..ff4e4734e 100644 --- a/apps/web/app/auth/confirm/route.ts +++ b/apps/web/app/auth/confirm/route.ts @@ -2,6 +2,7 @@ import { captureException, captureMessage } from '@sentry/nextjs'; import type { EmailOtpType } from '@supabase/supabase-js'; import { NextResponse } from 'next/server'; +import { recordSignupSideEffects } from '@/lib/auth/signup-side-effects'; import { createAuthRedirectResponse, getLocaleFromRedirectPath, @@ -18,9 +19,14 @@ const EMAIL_OTP_TYPES = new Set([ 'email', ]); +const SIGNUP_EMAIL_OTP_TYPES = new Set(['signup', 'email']); + const isEmailOtpType = (value: string | null): value is EmailOtpType => Boolean(value && EMAIL_OTP_TYPES.has(value as EmailOtpType)); +const isSignupEmailOtpType = (type: EmailOtpType) => + SIGNUP_EMAIL_OTP_TYPES.has(type); + const getDefaultSuccessPath = (type: EmailOtpType, locale: string) => type === 'recovery' ? `/${locale}/protected/update-password` @@ -64,7 +70,7 @@ export async function GET(request: Request) { } const supabase = await createClient(); - const { error } = await supabase.auth.verifyOtp({ + const { data, error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type, }); @@ -86,6 +92,28 @@ export async function GET(request: Request) { return NextResponse.redirect(`${origin}${loginPath}`); } + if (isSignupEmailOtpType(type)) { + const user = data.user ?? (await supabase.auth.getUser()).data.user; + + if (user) { + await recordSignupSideEffects(user, 'email'); + } else { + captureMessage('Email signup confirmation completed without a user.', { + level: 'error', + tags: { + area: 'auth', + flow: 'email-auth-confirm', + error_type: 'missing-confirmed-user', + }, + extra: { + type, + redirectTo, + safeRedirectPath, + }, + }); + } + } + return createAuthRedirectResponse( `${origin}${safeRedirectPath ?? getDefaultSuccessPath(type, locale)}`, ); diff --git a/apps/web/lib/auth/signup-side-effects.ts b/apps/web/lib/auth/signup-side-effects.ts new file mode 100644 index 000000000..08fd1cf3f --- /dev/null +++ b/apps/web/lib/auth/signup-side-effects.ts @@ -0,0 +1,53 @@ +import { captureMessage } from '@sentry/nextjs'; +import type { User } from '@supabase/supabase-js'; + +import PostHogClient from '@/lib/posthog'; +import { createOrRetrieveCustomer } from '@/lib/stripe/stripe-admin'; + +export type SignupLoginType = 'email' | 'social'; + +export async function recordSignupSideEffects( + user: User, + loginType: SignupLoginType, +) { + const email = user.email; + + if (!email) { + captureMessage( + 'Signup side effects skipped because user email is missing.', + { + level: 'error', + tags: { + area: 'auth', + flow: 'signup-side-effects', + }, + extra: { + userId: user.id, + loginType, + }, + }, + ); + return; + } + + const stripeId = await createOrRetrieveCustomer(user.id, email); + if (!stripeId) { + console.error('Failed to create Stripe customer.'); + captureMessage('Failed to create Stripe customer.', { + level: 'error', + user: { id: user.id, email }, + }); + } + + const posthog = PostHogClient(); + + posthog.capture({ + distinctId: user.id, + event: 'sign-up', + properties: { + login_type: loginType, + }, + }); + + await posthog.shutdown(); +} diff --git a/apps/web/tests/auth-confirm.test.ts b/apps/web/tests/auth-confirm.test.ts index fc425dba5..09de6c9db 100644 --- a/apps/web/tests/auth-confirm.test.ts +++ b/apps/web/tests/auth-confirm.test.ts @@ -2,8 +2,13 @@ import { captureException, captureMessage } from '@sentry/nextjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { GET } from '@/app/auth/confirm/route'; +import { recordSignupSideEffects } from '@/lib/auth/signup-side-effects'; import { createClient } from '@/lib/supabase/server'; +vi.mock('@/lib/auth/signup-side-effects', () => ({ + recordSignupSideEffects: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('next/server', () => ({ NextResponse: { redirect: (url: string | URL, init?: ResponseInit | number) => { @@ -74,6 +79,10 @@ describe('Email auth confirm route', () => { token_hash: 'pkce_hash', type: 'email', }); + expect(recordSignupSideEffects).toHaveBeenCalledWith( + { id: 'user-id', email: 'user@example.com' }, + 'email', + ); expect(captureException).not.toHaveBeenCalled(); expect(captureMessage).not.toHaveBeenCalled(); }); From ee385542e3f41aa1bbc09d6608d105ce76b6788c Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:20:02 +0200 Subject: [PATCH 03/11] fix(auth): make callback marker flow agnostic --- apps/web/app/auth/callback/route.ts | 16 ++++---- ...back-marker.ts => auth-callback-marker.ts} | 24 +++++------ apps/web/lib/supabase/auth-redirect.ts | 14 +++---- apps/web/lib/supabase/constants.ts | 2 +- apps/web/lib/supabase/middleware.ts | 36 ++++++++--------- ...r.test.ts => auth-callback-marker.test.ts} | 40 +++++++++---------- apps/web/tests/auth-callback.test.ts | 4 +- apps/web/tests/auth-confirm.test.ts | 2 +- docs/devops.md | 9 +++-- 9 files changed, 73 insertions(+), 74 deletions(-) rename apps/web/lib/supabase/{oauth-callback-marker.ts => auth-callback-marker.ts} (60%) rename apps/web/tests/{oauth-callback-marker.test.ts => auth-callback-marker.test.ts} (65%) diff --git a/apps/web/app/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts index abecbae74..3c52c81ae 100644 --- a/apps/web/app/auth/callback/route.ts +++ b/apps/web/app/auth/callback/route.ts @@ -8,7 +8,7 @@ import { getLocaleFromRedirectPath, getSafeAuthRedirectPath, } from '@/lib/supabase/auth-redirect'; -import { OAUTH_CALLBACK_COOKIE_NAME } from '@/lib/supabase/constants'; +import { AUTH_CALLBACK_COOKIE_NAME } from '@/lib/supabase/constants'; import { createClient } from '@/lib/supabase/server'; import { routing } from '@/src/i18n/routing'; @@ -64,7 +64,7 @@ const isPkceCodeVerifierMissingError = (error: unknown) => { ); }; -const getOauthCallbackCookieContext = (request: Request) => { +const getAuthCallbackCookieContext = (request: Request) => { const cookieHeader = request.headers.get('cookie') ?? ''; const cookieNames = cookieHeader .split(';') @@ -85,8 +85,8 @@ const getOauthCallbackCookieContext = (request: Request) => { hasSupabaseCodeVerifierCookie: supabaseCookieNames.some((name) => name.includes('code-verifier'), ), - hasOauthCallbackMarkerCookie: cookieNames.includes( - OAUTH_CALLBACK_COOKIE_NAME, + hasAuthCallbackMarkerCookie: cookieNames.includes( + AUTH_CALLBACK_COOKIE_NAME, ), }; }; @@ -103,7 +103,7 @@ export async function GET(request: Request) { const locale = getLocaleFromRedirectPath(safeRedirectPath); const loginPath = `/${locale}/login`; const oauthCodeContext = getOauthCodeFingerprint(code); - const oauthCookieContext = getOauthCallbackCookieContext(request); + const authCookieContext = getAuthCallbackCookieContext(request); const reportPkceCodeVerifierMissing = (error: unknown) => { captureMessage('OAuth callback missing PKCE code verifier.', { level: 'warning', @@ -116,7 +116,7 @@ export async function GET(request: Request) { redirectTo, locale, ...oauthCodeContext, - ...oauthCookieContext, + ...authCookieContext, errorMessage: getErrorMessage(error), }, }); @@ -148,7 +148,7 @@ export async function GET(request: Request) { redirectTo, locale, ...oauthCodeContext, - ...oauthCookieContext, + ...authCookieContext, }, }); @@ -199,7 +199,7 @@ export async function GET(request: Request) { redirectTo, locale, ...oauthCodeContext, - ...oauthCookieContext, + ...authCookieContext, }, }); diff --git a/apps/web/lib/supabase/oauth-callback-marker.ts b/apps/web/lib/supabase/auth-callback-marker.ts similarity index 60% rename from apps/web/lib/supabase/oauth-callback-marker.ts rename to apps/web/lib/supabase/auth-callback-marker.ts index b65d40b1d..0b283b7af 100644 --- a/apps/web/lib/supabase/oauth-callback-marker.ts +++ b/apps/web/lib/supabase/auth-callback-marker.ts @@ -1,10 +1,10 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; -import { OAUTH_CALLBACK_COOKIE_NAME } from './constants'; +import { AUTH_CALLBACK_COOKIE_NAME } from './constants'; -export const OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS = 60; +export const AUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS = 60; -function getOauthCallbackMarkerSecret(): string | null { +function getAuthCallbackMarkerSecret(): string | null { return ( process.env.OAUTH_CALLBACK_MARKER_SECRET ?? process.env.API_KEY_HMAC_SECRET ?? @@ -12,22 +12,20 @@ function getOauthCallbackMarkerSecret(): string | null { ); } -function createOauthCallbackMarkerSignature(expiresAt: number): string | null { - const secret = getOauthCallbackMarkerSecret(); +function createAuthCallbackMarkerSignature(expiresAt: number): string | null { + const secret = getAuthCallbackMarkerSecret(); if (!secret) { return null; } return createHmac('sha256', secret) - .update(`${OAUTH_CALLBACK_COOKIE_NAME}.${expiresAt}`) + .update(`${AUTH_CALLBACK_COOKIE_NAME}.${expiresAt}`) .digest('hex'); } -export function createOauthCallbackMarkerValue( - now = Date.now(), -): string | null { - const expiresAt = now + OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS * 1000; - const signature = createOauthCallbackMarkerSignature(expiresAt); +export function createAuthCallbackMarkerValue(now = Date.now()): string | null { + const expiresAt = now + AUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS * 1000; + const signature = createAuthCallbackMarkerSignature(expiresAt); if (!signature) { return null; @@ -36,7 +34,7 @@ export function createOauthCallbackMarkerValue( return `${expiresAt}.${signature}`; } -export function verifyOauthCallbackMarkerValue( +export function verifyAuthCallbackMarkerValue( value: string | undefined, now = Date.now(), ): boolean { @@ -56,7 +54,7 @@ export function verifyOauthCallbackMarkerValue( return false; } - const expectedSignature = createOauthCallbackMarkerSignature(expiresAt); + const expectedSignature = createAuthCallbackMarkerSignature(expiresAt); if (!expectedSignature) { return false; } diff --git a/apps/web/lib/supabase/auth-redirect.ts b/apps/web/lib/supabase/auth-redirect.ts index 3a079847d..a8ae22fcf 100644 --- a/apps/web/lib/supabase/auth-redirect.ts +++ b/apps/web/lib/supabase/auth-redirect.ts @@ -1,11 +1,11 @@ import { NextResponse } from 'next/server'; import { i18n, type Locale } from '@/lib/i18n/i18n-config'; -import { OAUTH_CALLBACK_COOKIE_NAME } from './constants'; import { - createOauthCallbackMarkerValue, - OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, -} from './oauth-callback-marker'; + AUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, + createAuthCallbackMarkerValue, +} from './auth-callback-marker'; +import { AUTH_CALLBACK_COOKIE_NAME } from './constants'; export const getSafeAuthRedirectPath = ( value: string | null, @@ -44,14 +44,14 @@ export const getLocaleFromRedirectPath = ( export const createAuthRedirectResponse = (url: string) => { const response = NextResponse.redirect(url); - const markerValue = createOauthCallbackMarkerValue(); + const markerValue = createAuthCallbackMarkerValue(); if (markerValue) { response.cookies.set({ - name: OAUTH_CALLBACK_COOKIE_NAME, + name: AUTH_CALLBACK_COOKIE_NAME, value: markerValue, httpOnly: true, - maxAge: OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, + maxAge: AUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', diff --git a/apps/web/lib/supabase/constants.ts b/apps/web/lib/supabase/constants.ts index 95e07ba59..a3aafdbff 100644 --- a/apps/web/lib/supabase/constants.ts +++ b/apps/web/lib/supabase/constants.ts @@ -1,5 +1,5 @@ export const MAX_FREE_GENERATIONS = 10; -export const OAUTH_CALLBACK_COOKIE_NAME = 'sv_oauth_callback_ok'; +export const AUTH_CALLBACK_COOKIE_NAME = 'sv_auth_callback_ok'; export const MINIMUM_CREDITS_FOR_CALL = 999; export const CREDITS_PER_MINUTE = 2000; diff --git a/apps/web/lib/supabase/middleware.ts b/apps/web/lib/supabase/middleware.ts index b4c8d5c9f..ced437c5b 100644 --- a/apps/web/lib/supabase/middleware.ts +++ b/apps/web/lib/supabase/middleware.ts @@ -2,8 +2,8 @@ import { captureMessage } from '@sentry/nextjs'; import { type NextRequest, NextResponse } from 'next/server'; import { routing } from '@/src/i18n/routing'; -import { OAUTH_CALLBACK_COOKIE_NAME } from './constants'; -import { verifyOauthCallbackMarkerValue } from './oauth-callback-marker'; +import { verifyAuthCallbackMarkerValue } from './auth-callback-marker'; +import { AUTH_CALLBACK_COOKIE_NAME } from './constants'; import { createClient } from './server'; const routesPerLocale = (routes: string[]): string[] => @@ -13,9 +13,9 @@ const routesPerLocale = (routes: string[]): string[] => ), ); -const clearOauthCallbackCookie = (response: NextResponse) => { +const clearAuthCallbackCookie = (response: NextResponse) => { response.cookies.set({ - name: OAUTH_CALLBACK_COOKIE_NAME, + name: AUTH_CALLBACK_COOKIE_NAME, value: '', httpOnly: true, maxAge: 0, @@ -65,11 +65,11 @@ export const updateSession = async ( try { const { pathname } = request.nextUrl; const supabaseResponse = response; - const rawOauthCallbackMarker = request.cookies.get( - OAUTH_CALLBACK_COOKIE_NAME, + const rawAuthCallbackMarker = request.cookies.get( + AUTH_CALLBACK_COOKIE_NAME, )?.value; - const hasOauthCallbackMarker = verifyOauthCallbackMarkerValue( - rawOauthCallbackMarker, + const hasAuthCallbackMarker = verifyAuthCallbackMarkerValue( + rawAuthCallbackMarker, ); const supabase = await createClient(); @@ -86,14 +86,14 @@ export const updateSession = async ( supabaseResponse, ); - if (hasOauthCallbackMarker) { + if (hasAuthCallbackMarker) { captureMessage( - 'OAuth callback completed but dashboard session was missing.', + 'Auth callback completed but dashboard session was missing.', { level: 'error', tags: { area: 'auth', - flow: 'oauth-callback', + flow: 'auth-callback', }, extra: { pathname, @@ -102,17 +102,17 @@ export const updateSession = async ( }, ); - return clearOauthCallbackCookie(redirectResponse); + return clearAuthCallbackCookie(redirectResponse); } console.log( - 'Dashboard request missing user without valid OAuth callback marker', + 'Dashboard request missing user without valid auth callback marker', { pathname, locale, - hasRawOauthCallbackMarker: Boolean(rawOauthCallbackMarker), - rawOauthCallbackMarkerLength: rawOauthCallbackMarker?.length ?? 0, - hasOauthCallbackMarker, + hasRawAuthCallbackMarker: Boolean(rawAuthCallbackMarker), + rawAuthCallbackMarkerLength: rawAuthCallbackMarker?.length ?? 0, + hasAuthCallbackMarker, }, ); @@ -139,8 +139,8 @@ export const updateSession = async ( ); } - if (hasOauthCallbackMarker && dashboardPath) { - return clearOauthCallbackCookie(supabaseResponse); + if (hasAuthCallbackMarker && dashboardPath) { + return clearAuthCallbackCookie(supabaseResponse); } // IMPORTANT: You *must* return the supabaseResponse object as it is. If you're diff --git a/apps/web/tests/oauth-callback-marker.test.ts b/apps/web/tests/auth-callback-marker.test.ts similarity index 65% rename from apps/web/tests/oauth-callback-marker.test.ts rename to apps/web/tests/auth-callback-marker.test.ts index 6a0f63118..823e46249 100644 --- a/apps/web/tests/oauth-callback-marker.test.ts +++ b/apps/web/tests/auth-callback-marker.test.ts @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { - createOauthCallbackMarkerValue, - OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, - verifyOauthCallbackMarkerValue, -} from '@/lib/supabase/oauth-callback-marker'; + AUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS, + createAuthCallbackMarkerValue, + verifyAuthCallbackMarkerValue, +} from '@/lib/supabase/auth-callback-marker'; -describe('oauth callback marker', () => { +describe('auth callback marker', () => { const originalSecret = process.env.API_KEY_HMAC_SECRET; afterEach(() => { @@ -22,23 +22,23 @@ describe('oauth callback marker', () => { process.env.API_KEY_HMAC_SECRET = 'test-secret'; const now = Date.now(); - const marker = createOauthCallbackMarkerValue(now); + const marker = createAuthCallbackMarkerValue(now); expect(marker).not.toBeNull(); - expect(verifyOauthCallbackMarkerValue(marker ?? undefined, now)).toBe(true); + expect(verifyAuthCallbackMarkerValue(marker ?? undefined, now)).toBe(true); }); it('uses the configured max age when generating the marker expiry', () => { process.env.API_KEY_HMAC_SECRET = 'test-secret'; const now = 1_700_000_000_000; - const marker = createOauthCallbackMarkerValue(now); + const marker = createAuthCallbackMarkerValue(now); expect(marker).not.toBeNull(); const [rawExpiresAt] = (marker ?? '').split('.'); expect(Number(rawExpiresAt)).toBe( - now + OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS * 1000, + now + AUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS * 1000, ); }); @@ -46,13 +46,13 @@ describe('oauth callback marker', () => { process.env.API_KEY_HMAC_SECRET = 'test-secret'; const now = 1_700_000_000_000; - const marker = createOauthCallbackMarkerValue(now); + const marker = createAuthCallbackMarkerValue(now); expect(marker).not.toBeNull(); expect( - verifyOauthCallbackMarkerValue( + verifyAuthCallbackMarkerValue( marker ?? undefined, - now + OAUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS * 1000 + 1, + now + AUTH_CALLBACK_COOKIE_MAX_AGE_SECONDS * 1000 + 1, ), ).toBe(false); }); @@ -60,7 +60,7 @@ describe('oauth callback marker', () => { it('rejects tampered signatures', () => { process.env.API_KEY_HMAC_SECRET = 'test-secret'; - const marker = createOauthCallbackMarkerValue(1_700_000_000_000); + const marker = createAuthCallbackMarkerValue(1_700_000_000_000); expect(marker).not.toBeNull(); const [expiresAt, signature] = (marker ?? '').split('.'); @@ -69,7 +69,7 @@ describe('oauth callback marker', () => { }`; expect( - verifyOauthCallbackMarkerValue( + verifyAuthCallbackMarkerValue( `${expiresAt}.${tamperedSignature}`, 1_700_000_000_000, ), @@ -79,36 +79,36 @@ describe('oauth callback marker', () => { it('rejects malformed marker values with extra segments', () => { process.env.API_KEY_HMAC_SECRET = 'test-secret'; - const marker = createOauthCallbackMarkerValue(1_700_000_000_000); + const marker = createAuthCallbackMarkerValue(1_700_000_000_000); expect(marker).not.toBeNull(); expect( - verifyOauthCallbackMarkerValue(`${marker}.extra`, 1_700_000_000_000), + verifyAuthCallbackMarkerValue(`${marker}.extra`, 1_700_000_000_000), ).toBe(false); }); it('returns null when the marker secret is unavailable', () => { delete process.env.API_KEY_HMAC_SECRET; - const marker = createOauthCallbackMarkerValue(1_700_000_000_000); + const marker = createAuthCallbackMarkerValue(1_700_000_000_000); expect(marker).toBeNull(); }); it('returns false when verifying without a marker secret', () => { process.env.API_KEY_HMAC_SECRET = 'test-secret'; - const marker = createOauthCallbackMarkerValue(1_700_000_000_000); + const marker = createAuthCallbackMarkerValue(1_700_000_000_000); delete process.env.API_KEY_HMAC_SECRET; expect( - verifyOauthCallbackMarkerValue(marker ?? undefined, 1_700_000_000_000), + verifyAuthCallbackMarkerValue(marker ?? undefined, 1_700_000_000_000), ).toBe(false); }); it('returns false for missing marker values', () => { process.env.API_KEY_HMAC_SECRET = 'test-secret'; - expect(verifyOauthCallbackMarkerValue(undefined, Date.now())).toBe(false); + expect(verifyAuthCallbackMarkerValue(undefined, Date.now())).toBe(false); }); }); diff --git a/apps/web/tests/auth-callback.test.ts b/apps/web/tests/auth-callback.test.ts index 325a93fa9..d59dbb124 100644 --- a/apps/web/tests/auth-callback.test.ts +++ b/apps/web/tests/auth-callback.test.ts @@ -53,7 +53,7 @@ describe('OAuth callback route', () => { { headers: { cookie: - 'sb-test-auth-token=token; sv_oauth_callback_ok=marker-value', + 'sb-test-auth-token=token; sv_auth_callback_ok=marker-value', }, }, ), @@ -84,7 +84,7 @@ describe('OAuth callback route', () => { supabaseCookieCount: 1, hasSupabaseAuthCookie: true, hasSupabaseCodeVerifierCookie: false, - hasOauthCallbackMarkerCookie: true, + hasAuthCallbackMarkerCookie: true, errorMessage: 'PKCE code verifier not found in storage.', }), }), diff --git a/apps/web/tests/auth-confirm.test.ts b/apps/web/tests/auth-confirm.test.ts index 09de6c9db..eeac28165 100644 --- a/apps/web/tests/auth-confirm.test.ts +++ b/apps/web/tests/auth-confirm.test.ts @@ -73,7 +73,7 @@ describe('Email auth confirm route', () => { 'https://sexyvoice.ai/es/dashboard?source=email', ); expect(response.headers.get('set-cookie')).toContain( - 'sv_oauth_callback_ok=', + 'sv_auth_callback_ok=', ); expect(verifyOtp).toHaveBeenCalledWith({ token_hash: 'pkce_hash', diff --git a/docs/devops.md b/docs/devops.md index fb67a90ee..867d8d6a6 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -206,7 +206,8 @@ Notes: Notes: - `API_KEY_HMAC_SECRET` is used for HMAC hashing of external API keys. - `OAUTH_CALLBACK_MARKER_SECRET` is the preferred dedicated secret for signing - and verifying the short-lived OAuth callback marker cookie. + and verifying the short-lived auth callback marker cookie used after OAuth + callbacks and email confirmations. - If `OAUTH_CALLBACK_MARKER_SECRET` is unset, code may fall back to `API_KEY_HMAC_SECRET`, but a dedicated secret is recommended. @@ -345,7 +346,7 @@ Example structure: - Rotate secrets carefully and document the blast radius before doing so. - Validate auth, payments, storage uploads, and API key flows after secret changes. -- Keep OAuth callback marker signing isolated from API key hashing where +- Keep auth callback marker signing isolated from API key hashing where possible. - Use production-only secure cookies where supported. @@ -478,7 +479,7 @@ output of `sentry-cli issues list` (first column). ## Troubleshooting Checklist -### OAuth callback/session issues +### Auth callback/session issues Check: - `NEXT_PUBLIC_SUPABASE_URL` @@ -486,7 +487,7 @@ Check: - `SUPABASE_SERVICE_ROLE_KEY` - `OAUTH_CALLBACK_MARKER_SECRET` - redirect URL configuration in Supabase / OAuth provider -- Sentry events tagged for OAuth callback flow +- Sentry events tagged for auth callback or OAuth callback flow - Supabase email templates use `/auth/confirm?token_hash={{ .TokenHash }}` for email confirmation and password recovery, not `/auth/callback` From e288481262e184cc0ce61d8bde7f5d0c57dd0020 Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:20:51 +0200 Subject: [PATCH 04/11] fix(auth): preserve redirect hash fragments --- apps/web/lib/supabase/auth-redirect.ts | 2 +- apps/web/tests/auth-confirm.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/supabase/auth-redirect.ts b/apps/web/lib/supabase/auth-redirect.ts index a8ae22fcf..ec8a6e194 100644 --- a/apps/web/lib/supabase/auth-redirect.ts +++ b/apps/web/lib/supabase/auth-redirect.ts @@ -26,7 +26,7 @@ export const getSafeAuthRedirectPath = ( return null; } - return `${redirectUrl.pathname}${redirectUrl.search}`; + return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`; } catch { return null; } diff --git a/apps/web/tests/auth-confirm.test.ts b/apps/web/tests/auth-confirm.test.ts index eeac28165..01a99c4a4 100644 --- a/apps/web/tests/auth-confirm.test.ts +++ b/apps/web/tests/auth-confirm.test.ts @@ -64,13 +64,13 @@ describe('Email auth confirm route', () => { const response = await GET( new Request( - 'https://sexyvoice.ai/auth/confirm?token_hash=pkce_hash&type=email&redirect_to=https%3A%2F%2Fsexyvoice.ai%2Fes%2Fdashboard%3Fsource%3Demail', + 'https://sexyvoice.ai/auth/confirm?token_hash=pkce_hash&type=email&redirect_to=https%3A%2F%2Fsexyvoice.ai%2Fes%2Fdashboard%3Fsource%3Demail%23billing', ), ); expect(response.status).toBe(307); expect(response.headers.get('location')).toBe( - 'https://sexyvoice.ai/es/dashboard?source=email', + 'https://sexyvoice.ai/es/dashboard?source=email#billing', ); expect(response.headers.get('set-cookie')).toContain( 'sv_auth_callback_ok=', From 2801d7d105901644548a07fdacb5a976dd65972d Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:21:51 +0200 Subject: [PATCH 05/11] fix(auth): normalize safe redirect paths --- apps/web/lib/supabase/auth-redirect.ts | 2 +- apps/web/tests/auth-confirm.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/supabase/auth-redirect.ts b/apps/web/lib/supabase/auth-redirect.ts index ec8a6e194..b8b88056e 100644 --- a/apps/web/lib/supabase/auth-redirect.ts +++ b/apps/web/lib/supabase/auth-redirect.ts @@ -26,7 +26,7 @@ export const getSafeAuthRedirectPath = ( return null; } - return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`; + return `/${redirectUrl.pathname.replace(/^\/+/, '')}${redirectUrl.search}${redirectUrl.hash}`; } catch { return null; } diff --git a/apps/web/tests/auth-confirm.test.ts b/apps/web/tests/auth-confirm.test.ts index 01a99c4a4..8d88b456a 100644 --- a/apps/web/tests/auth-confirm.test.ts +++ b/apps/web/tests/auth-confirm.test.ts @@ -113,6 +113,28 @@ describe('Email auth confirm route', () => { }); }); + it('normalizes same-origin redirect paths with duplicate leading slashes', async () => { + const verifyOtp = vi.fn().mockResolvedValue({ + data: { user: { id: 'user-id', email: 'user@example.com' } }, + error: null, + }); + + vi.mocked(createClient).mockResolvedValueOnce({ + auth: { verifyOtp }, + } as unknown as Awaited>); + + const response = await GET( + new Request( + 'https://sexyvoice.ai/auth/confirm?token_hash=pkce_hash&type=email&redirect_to=https%3A%2F%2Fsexyvoice.ai%2F%2Fevil.com%3Fsource%3Demail%23billing', + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://sexyvoice.ai/evil.com?source=email#billing', + ); + }); + it('reports malformed confirmation links without calling Supabase', async () => { const response = await GET( new Request('https://sexyvoice.ai/auth/confirm?type=email'), From 5231044dc009cc29784a1dc870e2446b111e9e8e Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:25:04 +0200 Subject: [PATCH 06/11] fix(auth): honor locale hints in email confirm --- apps/web/app/auth/confirm/route.ts | 8 +++- apps/web/lib/supabase/auth-redirect.ts | 51 ++++++++++++++++++++++++-- apps/web/tests/auth-confirm.test.ts | 20 +++++++++- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/apps/web/app/auth/confirm/route.ts b/apps/web/app/auth/confirm/route.ts index ff4e4734e..46de834d2 100644 --- a/apps/web/app/auth/confirm/route.ts +++ b/apps/web/app/auth/confirm/route.ts @@ -5,7 +5,7 @@ import { NextResponse } from 'next/server'; import { recordSignupSideEffects } from '@/lib/auth/signup-side-effects'; import { createAuthRedirectResponse, - getLocaleFromRedirectPath, + getLocaleFromAuthHints, getSafeAuthRedirectPath, } from '@/lib/supabase/auth-redirect'; import { createClient } from '@/lib/supabase/server'; @@ -44,7 +44,11 @@ export async function GET(request: Request) { requestUrl.searchParams.get('redirect_to') ?? requestUrl.searchParams.get('next'); const safeRedirectPath = getSafeAuthRedirectPath(redirectTo, origin); - const locale = getLocaleFromRedirectPath(safeRedirectPath); + const locale = getLocaleFromAuthHints({ + acceptLanguage: request.headers.get('accept-language'), + locale: requestUrl.searchParams.get('lang'), + redirectPath: safeRedirectPath, + }); const loginPath = `/${locale}/login`; if (!(tokenHash && isEmailOtpType(type))) { diff --git a/apps/web/lib/supabase/auth-redirect.ts b/apps/web/lib/supabase/auth-redirect.ts index b8b88056e..9f56ff44f 100644 --- a/apps/web/lib/supabase/auth-redirect.ts +++ b/apps/web/lib/supabase/auth-redirect.ts @@ -32,14 +32,57 @@ export const getSafeAuthRedirectPath = ( } }; +const getSupportedLocale = ( + value: string | null | undefined, +): Locale | null => { + const locale = value?.trim().toLowerCase().split('-')[0]; + + return i18n.locales.includes(locale as Locale) ? (locale as Locale) : null; +}; + +const getLocaleFromAcceptLanguage = ( + acceptLanguage: string | null, +): Locale | null => + acceptLanguage + ?.split(',') + .map((languageRange) => { + const [languageTag, ...parameters] = languageRange.trim().split(';'); + const qualityParameter = parameters + .map((parameter) => parameter.trim()) + .find((parameter) => parameter.startsWith('q=')); + const quality = qualityParameter + ? Number(qualityParameter.slice('q='.length)) + : 1; + + return { + locale: getSupportedLocale(languageTag), + quality: Number.isFinite(quality) ? quality : 0, + }; + }) + .filter(({ locale, quality }) => locale && quality > 0) + .sort((a, b) => b.quality - a.quality)[0]?.locale ?? null; + export const getLocaleFromRedirectPath = ( redirectPath: string | null, ): Locale => { - const locale = redirectPath?.split('/')[1]; + return getSupportedLocale(redirectPath?.split('/')[1]) ?? i18n.defaultLocale; +}; - return i18n.locales.includes(locale as Locale) - ? (locale as Locale) - : i18n.defaultLocale; +export const getLocaleFromAuthHints = ({ + acceptLanguage, + locale, + redirectPath, +}: { + acceptLanguage: string | null; + locale: string | null; + redirectPath: string | null; +}): Locale => { + return ( + getSupportedLocale(redirectPath?.split('/')[1]) ?? + getSupportedLocale(locale) ?? + getLocaleFromAcceptLanguage(acceptLanguage) ?? + i18n.defaultLocale + ); }; export const createAuthRedirectResponse = (url: string) => { diff --git a/apps/web/tests/auth-confirm.test.ts b/apps/web/tests/auth-confirm.test.ts index 8d88b456a..aedc915db 100644 --- a/apps/web/tests/auth-confirm.test.ts +++ b/apps/web/tests/auth-confirm.test.ts @@ -137,12 +137,12 @@ describe('Email auth confirm route', () => { it('reports malformed confirmation links without calling Supabase', async () => { const response = await GET( - new Request('https://sexyvoice.ai/auth/confirm?type=email'), + new Request('https://sexyvoice.ai/auth/confirm?type=email&lang=fr'), ); expect(response.status).toBe(307); expect(response.headers.get('location')).toBe( - 'https://sexyvoice.ai/en/login', + 'https://sexyvoice.ai/fr/login', ); expect(createClient).not.toHaveBeenCalled(); expect(captureMessage).toHaveBeenCalledWith( @@ -162,6 +162,22 @@ describe('Email auth confirm route', () => { ); }); + it('falls back to Accept-Language when confirmation links omit locale hints', async () => { + const response = await GET( + new Request('https://sexyvoice.ai/auth/confirm?type=email', { + headers: { + 'accept-language': 'es-ES,es;q=0.9,en;q=0.8', + }, + }), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://sexyvoice.ai/es/login', + ); + expect(createClient).not.toHaveBeenCalled(); + }); + it('reports verifyOtp failures and returns users to localized login', async () => { const verifyError = new Error('Token has expired or is invalid'); const verifyOtp = vi.fn().mockResolvedValue({ From e2e46942d069bfcbd057bcc1ef6e1ce587da5ad8 Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:25:47 +0200 Subject: [PATCH 07/11] chore(auth): type-check email otp constants --- apps/web/app/auth/confirm/route.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/app/auth/confirm/route.ts b/apps/web/app/auth/confirm/route.ts index 46de834d2..f2d9bcb91 100644 --- a/apps/web/app/auth/confirm/route.ts +++ b/apps/web/app/auth/confirm/route.ts @@ -10,16 +10,19 @@ import { } from '@/lib/supabase/auth-redirect'; import { createClient } from '@/lib/supabase/server'; -const EMAIL_OTP_TYPES = new Set([ +const EMAIL_OTP_TYPES: ReadonlySet = new Set([ 'signup', 'invite', 'magiclink', 'recovery', 'email_change', 'email', -]); +] satisfies EmailOtpType[]); -const SIGNUP_EMAIL_OTP_TYPES = new Set(['signup', 'email']); +const SIGNUP_EMAIL_OTP_TYPES: ReadonlySet = new Set([ + 'signup', + 'email', +] satisfies EmailOtpType[]); const isEmailOtpType = (value: string | null): value is EmailOtpType => Boolean(value && EMAIL_OTP_TYPES.has(value as EmailOtpType)); From f01349591d69916f919ba6dadb12dd4ae63c2614 Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:26:34 +0200 Subject: [PATCH 08/11] test(auth): cover recovery confirm default redirect --- apps/web/tests/auth-confirm.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/web/tests/auth-confirm.test.ts b/apps/web/tests/auth-confirm.test.ts index aedc915db..760da7e5b 100644 --- a/apps/web/tests/auth-confirm.test.ts +++ b/apps/web/tests/auth-confirm.test.ts @@ -178,6 +178,29 @@ describe('Email auth confirm route', () => { expect(createClient).not.toHaveBeenCalled(); }); + it('redirects recovery confirmations without redirect destinations to update password', async () => { + const verifyOtp = vi.fn().mockResolvedValue({ + data: { user: { id: 'user-id', email: 'user@example.com' } }, + error: null, + }); + + vi.mocked(createClient).mockResolvedValueOnce({ + auth: { verifyOtp }, + } as unknown as Awaited>); + + const response = await GET( + new Request( + 'https://sexyvoice.ai/auth/confirm?token_hash=pkce_hash&type=recovery', + ), + ); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe( + 'https://sexyvoice.ai/en/protected/update-password', + ); + expect(recordSignupSideEffects).not.toHaveBeenCalled(); + }); + it('reports verifyOtp failures and returns users to localized login', async () => { const verifyError = new Error('Token has expired or is invalid'); const verifyOtp = vi.fn().mockResolvedValue({ From 2ecd2803d5abb87fe771bd73a82421391bbcaf4e Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:27:32 +0200 Subject: [PATCH 09/11] docs(auth): document email template rollout order --- docs/devops.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/devops.md b/docs/devops.md index 867d8d6a6..99cdb5b15 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -247,6 +247,16 @@ Do not route email templates through `/auth/callback` and do not rely on same-origin destinations through Supabase `emailRedirectTo` / `redirectTo`, and `/auth/confirm` validates the destination before redirecting. +Deployment order matters for these template changes: + +1. Update the Supabase dashboard Confirm signup and Reset password templates. +2. Deploy the app version that serves `/auth/confirm` immediately after the + dashboard update. +3. On rollback, roll the app back first and revert the Supabase templates last. + +Avoid generating production auth links while the dashboard templates and +deployed app route support are intentionally out of sync. + ### Stripe - `STRIPE_SECRET_KEY` From 9d50c7a37efdb4d66881999087eee68d370a8bc3 Mon Sep 17 00:00:00 2001 From: gianpaj Date: Tue, 12 May 2026 15:27:57 +0200 Subject: [PATCH 10/11] docs(auth): note supabase redirect allowlist --- docs/devops.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/devops.md b/docs/devops.md index 99cdb5b15..3e72d059d 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -257,6 +257,12 @@ Deployment order matters for these template changes: Avoid generating production auth links while the dashboard templates and deployed app route support are intentionally out of sync. +Before testing signup or recovery links, verify the Supabase Auth URL +configuration allows every destination the app sends through +`emailRedirectTo` / `redirectTo`, including localized dashboard URLs for `en`, +`es`, `de`, `da`, `it`, and `fr`. If Supabase rejects the provided redirect, it +can fall back to the Site URL and drop the intended `redirect_to` destination. + ### Stripe - `STRIPE_SECRET_KEY` From a546dea2974f64c53ae693fecadf6f10820421e2 Mon Sep 17 00:00:00 2001 From: Gianfranco P <899175+gianpaj@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:26:35 +0200 Subject: [PATCH 11/11] fix(auth): align email signup e2e with server-side signup route (#428) * fix(auth): align email signup e2e with server-side signup route Signup moved to POST /auth/signup, so Playwright mocks of the browser-side Supabase auth/v1/signup endpoint no longer intercept anything. - Pass locale from the signup form to the server route - Set emailRedirectTo to the localized dashboard URL (not /auth/callback) - Short-circuit Supabase in E2E mode and return emailRedirectTo in the response - Update auth-email.spec.ts to assert the /auth/signup response payload * fix(auth): stop trusting Origin header for email redirect URLs Use NEXT_PUBLIC_SITE_URL as the primary origin for signup and password reset redirects, with request.url as a fallback in route handlers only. Reject signup when no trusted origin can be resolved. Extract getSiteUrlOrigin/getRequestOrigin helpers into auth-redirect and add unit tests covering spoofed Origin headers and invalid config. --------- Co-authored-by: Cursor Agent --- .../app/[lang]/(auth)/signup/signup-form.tsx | 2 +- apps/web/app/actions.ts | 5 +- apps/web/app/auth/signup/route.ts | 35 ++++++- apps/web/e2e/auth-email.spec.ts | 94 ++++--------------- apps/web/lib/supabase/auth-redirect.ts | 26 +++++ apps/web/tests/auth-redirect.test.ts | 51 ++++++++++ 6 files changed, 127 insertions(+), 86 deletions(-) create mode 100644 apps/web/tests/auth-redirect.test.ts diff --git a/apps/web/app/[lang]/(auth)/signup/signup-form.tsx b/apps/web/app/[lang]/(auth)/signup/signup-form.tsx index 0e1d9da20..febd37567 100644 --- a/apps/web/app/[lang]/(auth)/signup/signup-form.tsx +++ b/apps/web/app/[lang]/(auth)/signup/signup-form.tsx @@ -65,7 +65,7 @@ export function SignUpForm({ lang }: { lang: Locale }) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, lang }), }); if (res.ok) { diff --git a/apps/web/app/actions.ts b/apps/web/app/actions.ts index cb123f10c..88928f9bf 100644 --- a/apps/web/app/actions.ts +++ b/apps/web/app/actions.ts @@ -1,11 +1,11 @@ 'use server'; import * as Sentry from '@sentry/nextjs'; -import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { z } from 'zod'; import type { Locale } from '@/lib/i18n/i18n-config'; import { deleteFileFromR2 } from '@/lib/storage/upload'; +import { getSiteUrlOrigin } from '@/lib/supabase/auth-redirect'; import { createClient } from '@/lib/supabase/server'; import { encodedRedirect } from '@/lib/utils'; @@ -32,8 +32,7 @@ export const forgotPasswordAction = async (formData: FormData) => { } const supabase = await createClient(); - const origin = - (await headers()).get('origin') ?? process.env.NEXT_PUBLIC_SITE_URL; + const origin = getSiteUrlOrigin(); const callbackUrl = formData.get('callbackUrl')?.toString(); const updatePasswordPath = `/${lang}/protected/update-password?email=${encodeURIComponent(email)}`; diff --git a/apps/web/app/auth/signup/route.ts b/apps/web/app/auth/signup/route.ts index d864beeb4..856f64330 100644 --- a/apps/web/app/auth/signup/route.ts +++ b/apps/web/app/auth/signup/route.ts @@ -2,6 +2,11 @@ import { NextResponse } from 'next/server'; import z from 'zod'; import { isDisposableEmail } from '@/lib/disposable-email'; +import { isE2E } from '@/lib/e2e-mode'; +import { i18n, type Locale } from '@/lib/i18n/i18n-config'; +import { + getRequestOrigin, +} from '@/lib/supabase/auth-redirect'; import { createClient } from '@/lib/supabase/server'; interface ParsedError { @@ -48,9 +53,10 @@ function parseSignUpError( return { message: errorMessage }; } -export async function POST(request: Request) { - const supabase = await createClient(); +const getSignupEmailRedirectTo = (origin: string, lang: Locale) => + new URL(`/${lang}/dashboard`, origin).toString(); +export async function POST(request: Request) { let body: unknown; try { body = await request.json(); @@ -64,6 +70,7 @@ export async function POST(request: Request) { const schema = z.object({ email: z.email(), password: z.string().min(6).max(72), + lang: z.enum(i18n.locales).default(i18n.defaultLocale), }); const result = schema.safeParse(body); @@ -77,7 +84,16 @@ export async function POST(request: Request) { ); } - const { email, password } = result.data; + const { email, password, lang } = result.data; + const origin = getRequestOrigin(request); + if (!origin) { + return NextResponse.json( + { error: { message: 'Server configuration error' } }, + { status: 500 }, + ); + } + + const emailRedirectTo = getSignupEmailRedirectTo(origin, lang); if (isDisposableEmail(email)) { return NextResponse.json( @@ -86,11 +102,20 @@ export async function POST(request: Request) { ); } + if (isE2E()) { + return NextResponse.json( + { data: { message: 'User created', emailRedirectTo } }, + { status: 201 }, + ); + } + + const supabase = await createClient(); + const { error: signUpError, data } = await supabase.auth.signUp({ email, password, options: { - emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`, + emailRedirectTo, }, }); @@ -118,7 +143,7 @@ export async function POST(request: Request) { } return NextResponse.json( - { data: { message: 'User created' } }, + { data: { message: 'User created', emailRedirectTo } }, { status: 201 }, ); } diff --git a/apps/web/e2e/auth-email.spec.ts b/apps/web/e2e/auth-email.spec.ts index 026b7620b..af7c3e075 100644 --- a/apps/web/e2e/auth-email.spec.ts +++ b/apps/web/e2e/auth-email.spec.ts @@ -1,99 +1,39 @@ -import { expect, type Route, test } from '@playwright/test'; +import { expect, test } from '@playwright/test'; test.use({ storageState: { cookies: [], origins: [] } }); -const corsHeaders = (origin: string) => ({ - 'access-control-allow-headers': - 'authorization, apikey, content-type, x-client-info, x-supabase-api-version', - 'access-control-allow-methods': 'POST, OPTIONS', - 'access-control-allow-origin': origin, -}); - -const mockSignupUser = (email: string) => { - const now = new Date().toISOString(); - - return { - id: '11111111-1111-4111-8111-111111111111', - aud: 'authenticated', - role: 'authenticated', - email, - phone: '', - app_metadata: { - provider: 'email', - providers: ['email'], - }, - user_metadata: {}, - identities: [ - { - id: '11111111-1111-4111-8111-111111111111', - user_id: '11111111-1111-4111-8111-111111111111', - identity_id: '22222222-2222-4222-8222-222222222222', - provider: 'email', - email, - identity_data: { - email, - email_verified: false, - phone_verified: false, - sub: '11111111-1111-4111-8111-111111111111', - }, - last_sign_in_at: now, - created_at: now, - updated_at: now, - }, - ], - created_at: now, - updated_at: now, - }; -}; - test.describe('Email auth links', () => { - test.afterEach(async ({ page }) => { - await page.unroute('**/*'); - }); - test('email signup sends a final destination instead of /auth/callback', async ({ page, }) => { const email = `auth-email-${Date.now()}@example.com`; - let signupRedirectTo: string | null = null; - let signupBody: Record | null = null; - - await page.route('**/auth/v1/signup**', async (route: Route) => { - const request = route.request(); - const origin = new URL(page.url()).origin; - - if (request.method() === 'OPTIONS') { - await route.fulfill({ - status: 204, - headers: corsHeaders(origin), - }); - return; - } - - const requestUrl = new URL(request.url()); - signupRedirectTo = requestUrl.searchParams.get('redirect_to'); - signupBody = request.postDataJSON(); - - await route.fulfill({ - status: 200, - contentType: 'application/json', - headers: corsHeaders(origin), - body: JSON.stringify(mockSignupUser(email)), - }); - }); await page.goto('/en/signup'); const appOrigin = new URL(page.url()).origin; await page.getByLabel('Email address').fill(email); await page.getByLabel('Password').fill('Playwright-password-123'); + + const signupResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/auth/signup') && + response.request().method() === 'POST', + ); + await page.getByRole('button', { name: 'Sign up', exact: true }).click(); - await expect.poll(() => signupRedirectTo).toBe(`${appOrigin}/en/dashboard`); - expect(signupRedirectTo).not.toContain('/auth/callback'); + const signupResponse = await signupResponsePromise; + const signupBody = JSON.parse(signupResponse.request().postData() ?? '{}'); + const signupPayload = (await signupResponse.json()) as { + data?: { emailRedirectTo?: string }; + }; + + expect(signupPayload.data?.emailRedirectTo).toBe(`${appOrigin}/en/dashboard`); + expect(signupPayload.data?.emailRedirectTo).not.toContain('/auth/callback'); expect(signupBody).toMatchObject({ email, password: 'Playwright-password-123', + lang: 'en', }); await expect( page.getByText('Check your email inbox for verification'), diff --git a/apps/web/lib/supabase/auth-redirect.ts b/apps/web/lib/supabase/auth-redirect.ts index 9f56ff44f..de8d4daae 100644 --- a/apps/web/lib/supabase/auth-redirect.ts +++ b/apps/web/lib/supabase/auth-redirect.ts @@ -7,6 +7,32 @@ import { } from './auth-callback-marker'; import { AUTH_CALLBACK_COOKIE_NAME } from './constants'; +export const getSiteUrlOrigin = (): string | null => { + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (!siteUrl) { + return null; + } + + try { + return new URL(siteUrl).origin; + } catch { + return null; + } +}; + +export const getRequestOrigin = (request: Request): string | null => { + const siteUrlOrigin = getSiteUrlOrigin(); + if (siteUrlOrigin) { + return siteUrlOrigin; + } + + try { + return new URL(request.url).origin; + } catch { + return null; + } +}; + export const getSafeAuthRedirectPath = ( value: string | null, origin: string, diff --git a/apps/web/tests/auth-redirect.test.ts b/apps/web/tests/auth-redirect.test.ts new file mode 100644 index 000000000..fdfb297ee --- /dev/null +++ b/apps/web/tests/auth-redirect.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + getRequestOrigin, + getSiteUrlOrigin, +} from '@/lib/supabase/auth-redirect'; + +describe('auth redirect origin helpers', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('prefers NEXT_PUBLIC_SITE_URL over request.url', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://sexyvoice.ai'); + + expect( + getRequestOrigin( + new Request('http://localhost:3100/auth/signup', { + headers: { origin: 'https://evil.example' }, + }), + ), + ).toBe('https://sexyvoice.ai'); + }); + + it('falls back to request.url when NEXT_PUBLIC_SITE_URL is unset', () => { + delete process.env.NEXT_PUBLIC_SITE_URL; + + expect( + getRequestOrigin( + new Request('http://localhost:3100/auth/signup', { + headers: { origin: 'https://evil.example' }, + }), + ), + ).toBe('http://localhost:3100'); + }); + + it('returns null for invalid NEXT_PUBLIC_SITE_URL values', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'not-a-url'); + + expect(getSiteUrlOrigin()).toBeNull(); + expect( + getRequestOrigin(new Request('http://localhost:3100/auth/signup')), + ).toBe('http://localhost:3100'); + }); + + it('returns null when no trusted origin can be resolved', () => { + delete process.env.NEXT_PUBLIC_SITE_URL; + + expect(getRequestOrigin({ url: 'not-a-url' } as Request)).toBeNull(); + }); +});