Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions src/services/authService.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,18 +137,21 @@ export async function signInWithGoogleIdToken(
turnstileToken
) {
const supabase = requireClient();
const /** @type {Record<string, any>} */ 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<string, any>} */ credentials = {
provider: 'google',
token: idToken,
nonce,
access_token: accessToken,
};

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;
Expand Down
6 changes: 3 additions & 3 deletions src/services/authService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -103,7 +103,7 @@ describe('authService', () => {
token: 'token-123',
nonce: undefined,
access_token: undefined,
data: { cf_turnstile_response: 'turnstile-token' },
options: { captchaToken: 'turnstile-token' },
});
});

Expand Down Expand Up @@ -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' },
});
});

Expand Down
52 changes: 5 additions & 47 deletions supabase/functions/before-signup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down
Loading