diff --git a/.env.example b/.env.example index bb4e46f4..a2741294 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,8 @@ VITE_SUPABASE_ANON_KEY=your-anon-key VITE_GOOGLE_WEB_CLIENT_ID=your-web-client-id.apps.googleusercontent.com # Cloudflare Turnstile (signup bot gate) — public site key only -# Matching secret key is a Supabase Edge Function secret: TURNSTILE_SECRET_KEY +# Matching secret key goes in Supabase Dashboard → Authentication → CAPTCHA +# protection (Turnstile), NOT in a VITE_* var. Client sends it via captchaToken. VITE_TURNSTILE_SITE_KEY=0x4AAAAA...your-turnstile-site-key # PostHog analytics (optional — skip in local dev) diff --git a/src/services/authService.js b/src/services/authService.js index d53de2a4..6ec1c0d8 100644 --- a/src/services/authService.js +++ b/src/services/authService.js @@ -137,7 +137,10 @@ export async function signInWithGoogleIdToken( turnstileToken ) { const supabase = requireClient(); - const /** @type {Record} */ options = { + // signInWithIdToken does not accept user `data` metadata. Turnstile must go + // through options.captchaToken → gotrue_meta_security.captcha_token (verified + // by GoTrue when Auth CAPTCHA protection is enabled). + const /** @type {Record} */ credentials = { provider: 'google', token: idToken, nonce, @@ -145,10 +148,10 @@ export async function signInWithGoogleIdToken( }; if (turnstileToken) { - options.data = { cf_turnstile_response: turnstileToken }; + credentials.options = { captchaToken: turnstileToken }; } - const { error } = await supabase.auth.signInWithIdToken(options); + const { error } = await supabase.auth.signInWithIdToken(credentials); if (error) { throw error; diff --git a/src/services/authService.test.js b/src/services/authService.test.js index 20cec552..c5608e80 100644 --- a/src/services/authService.test.js +++ b/src/services/authService.test.js @@ -90,7 +90,7 @@ describe('authService', () => { }); }); - it('signInWithGoogleIdToken includes Turnstile token in user metadata when provided', async () => { + it('signInWithGoogleIdToken passes Turnstile token as captchaToken', async () => { await authService.signInWithGoogleIdToken( 'token-123', undefined, @@ -103,7 +103,7 @@ describe('authService', () => { token: 'token-123', nonce: undefined, access_token: undefined, - data: { cf_turnstile_response: 'turnstile-token' }, + options: { captchaToken: 'turnstile-token' }, }); }); @@ -146,7 +146,7 @@ describe('authService', () => { token: 'google-id-token', nonce: undefined, access_token: undefined, - data: { cf_turnstile_response: 'cf-turnstile-ok' }, + options: { captchaToken: 'cf-turnstile-ok' }, }); }); diff --git a/supabase/functions/before-signup/index.ts b/supabase/functions/before-signup/index.ts index c70ef6a8..2f1dd6fe 100644 --- a/supabase/functions/before-signup/index.ts +++ b/supabase/functions/before-signup/index.ts @@ -49,45 +49,6 @@ function getServiceClient() { ); } -/** - * Verify a Cloudflare Turnstile token via the siteverify endpoint. - * Returns { success: boolean } — always succeeds if TURNSTILE_SECRET_KEY is - * unset (graceful fallback for local dev or when Turnstile is not configured). - */ -async function verifyTurnstileToken( - token: string | undefined -): Promise<{ success: boolean }> { - const secretKey = Deno.env.get('TURNSTILE_SECRET_KEY'); - - if (!secretKey) { - return { success: true }; - } - - if (!token) { - return { success: false }; - } - - try { - const formData = new URLSearchParams(); - formData.append('secret', secretKey); - formData.append('response', token); - - const result = await fetch( - 'https://challenges.cloudflare.com/turnstile/v0/siteverify', - { - method: 'POST', - body: formData, - } - ); - - const data = await result.json(); - return { success: data.success === true }; - } catch (err) { - console.error('before-signup: Turnstile verification error', err); - return { success: false }; - } -} - export async function handleRequest(req) { if (req.method !== 'POST') { // Probes/scanners hit this URL with GET — log only, no Telegram noise. @@ -127,14 +88,11 @@ export async function handleRequest(req) { return await reject('missing_ip'); } - const turnstileToken = user.raw_user_meta_data?.cf_turnstile_response; - const turnstile = await verifyTurnstileToken( - typeof turnstileToken === 'string' ? turnstileToken : undefined - ); - - if (!turnstile.success) { - return await reject('turnstile_failed', { email, ip }); - } + // Turnstile is NOT checked here. Google OIDC uses signInWithIdToken, which + // cannot attach custom raw_user_meta_data. The client sends the token as + // options.captchaToken; enable Auth → CAPTCHA protection (Turnstile) in the + // Supabase dashboard so GoTrue verifies it before this hook runs. + // This hook still enforces IP bans + signup rate limits. const supabase = getServiceClient();