diff --git a/.env.example b/.env.example index 598cb3a..ec4371a 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ VITE_API_URL= # Set to "true" locally. NEVER set in Vercel env vars. VITE_DEV_MODE=true +# Cloudflare Turnstile +VITE_TURNSTILE_SITE_KEY= + # ── PostHog Analytics ───────────────────────────────────────────────────────── # Leave blank locally — PostHog is silently disabled when this key is missing. # Set both vars in your Vercel / production environment dashboard. diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml index a8d5b51..2aa29b3 100644 --- a/.github/workflows/greetings.yml +++ b/.github/workflows/greetings.yml @@ -1,4 +1,4 @@ -name: Greetings + name: Greetings on: pull_request_target: diff --git a/.gitignore b/.gitignore index 96ca793..e525b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,4 +62,27 @@ Models/ *.ntvs* *.njsproj *.sln -*.sw? \ No newline at end of file +*.sw? + +# Additional local / secret ignores +# Local build outputs +build/ + +# Alternate virtualenv names +myenv/ + +# Docker local overrides and runtime artifacts +docker-compose.override.yml +.docker/ + +# Hugging Face / ML cache +.hf/ +.cache/ + +# Secrets files +secrets.env +*.secret + +# Local DB / artifacts +*.sqlite3 +*.db \ No newline at end of file diff --git a/README.md b/README.md index f6436e4..3bf85e7 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ The setup script detects your environment automatically: ```env VITE_API_URL= # leave blank for local dev; Vite proxy handles /api/* VITE_DEV_MODE=true # enables DEV LOGIN button; never set true in production +VITE_TURNSTILE_SITE_KEY= # Cloudflare Turnstile site key ``` **Backend** — copy `backend/.env.example` to `backend/.env`: @@ -109,9 +110,14 @@ SUPABASE_KEY= SUPABASE_SERVICE_KEY= FRONTEND_URL=http://localhost:5173 API_BASE_URL=http://localhost:8000 +TURNSTILE_SECRET_KEY= DEV_BYPASS_AUTH=true # never set true in production ``` +> Note: When using production auth flows, set `VITE_TURNSTILE_SITE_KEY` in the frontend and `TURNSTILE_SECRET_KEY` in the backend. This enables Cloudflare Turnstile protection for auth endpoint requests. + +> Authentication start requests are rate limited in the backend to 5 requests per minute. Invalid or missing Turnstile tokens are rejected with a standard 400 response. + ### Available Scripts | Script | Description | diff --git a/backend/.env.example b/backend/.env.example index 810a353..d552fce 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -17,5 +17,19 @@ API_BASE_URL=http://localhost:8000 DEV_BYPASS_AUTH=true DEV_BYPASS_TOKEN=dev-local-bypass-token +# ── Cloudflare Turnstile — protect auth endpoints from automated abuse +TURNSTILE_SECRET_KEY= + +# ── ML Models (optional) ────────────────────────────────────────────────────── +# Leave unset → demo mode (random scores, no PyTorch needed). +# Uncomment to enable real ML inference: +# MODEL_DIR=/absolute/path/to/Models +# STREAM_A_MODEL=/absolute/path/to/Models/freshscan_stream_a_body.pth +# STREAM_B_MODEL=/absolute/path/to/Models/stream_b_checkpoint.pth + +# ── HF Hub (production HF Space only — set as Space secrets, NOT here) ──────── +# HF_MODEL_REPO=karansingh12/freshscan-models +# MODEL_TOKEN=hf_xxxxxxxxxxxxxxxxxxxx +========= # ── CORS ────────────────────────────────────────────────────────────────────── -CORS_ALLOW_ALL=true +CORS_ALLOW_ALL=true \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index ab578ce..22f8104 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,10 +7,12 @@ from contextlib import asynccontextmanager from typing import Optional from auth import get_current_user, get_google_oauth_url, exchange_code_for_session +from turnstile import TURNSTILE_SECRET_KEY, verify_turnstile_token from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware from rate_limiter import limiter + # Load .env file if present (python-dotenv) try: from dotenv import load_dotenv @@ -19,9 +21,10 @@ except ImportError: pass -from fastapi import FastAPI, File, Request, UploadFile, Form, HTTPException, Depends, Query +from fastapi import Body, FastAPI, File, UploadFile, Form, HTTPException, Depends, Query, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.responses import RedirectResponse, JSONResponse +from slowapi import _rate_limit_exceeded_handler from supabase import create_client, Client from PIL import Image @@ -35,7 +38,6 @@ _torch_available = False print("WARNING: PyTorch not installed. Scan endpoints will return 503.") - # ── Configuration ───────────────────────────────────────────────────────────── # All secrets MUST come from environment variables — no hardcoded fallbacks. SUPABASE_URL = os.environ.get("SUPABASE_URL", "") @@ -93,8 +95,6 @@ async def lifespan(app: FastAPI): ) yield -from fastapi import FastAPI - app = FastAPI(title="FreshScan AI", version="1.1.0", lifespan=lifespan) _cors_origins = ( @@ -123,6 +123,7 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) app.state.limiter = limiter +app.add_exception_handler(429, _rate_limit_exceeded_handler) app.add_middleware(SlowAPIMiddleware) @app.exception_handler(RateLimitExceeded) @@ -370,12 +371,44 @@ async def api_health_check(): """Health check endpoint — no auth or DB required.""" return {"status": "ok"} -@app.get("/api/v1/auth/login/google") -async def login_google(): +def _auth_redirect_url() -> str: callback_url = f"{API_BASE_URL}/api/v1/auth/callback" + return get_google_oauth_url(redirect_to=callback_url) + + +async def _verify_turnstile(turnstile_token: str | None, request: Request) -> None: + if TURNSTILE_SECRET_KEY: + client_host = request.client.host if request.client else None + await verify_turnstile_token(turnstile_token, client_host) + + +@app.get("/api/v1/auth/login/google") +@limiter.limit("5/minute") +async def login_google_get( + request: Request, + turnstile_token: str | None = Query(None, alias="turnstile_token"), +): try: - url = get_google_oauth_url(redirect_to=callback_url) - return RedirectResponse(url=url) + await _verify_turnstile(turnstile_token, request) + return RedirectResponse(url=_auth_redirect_url()) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not generate OAuth URL: {exc}") + + +@app.post("/api/v1/auth/login/google") +@limiter.limit("5/minute") +async def login_google_post( + request: Request, + payload: dict | None = Body(None), +): + turnstile_token = payload.get("turnstile_token") if payload else None + try: + await _verify_turnstile(turnstile_token, request) + return {"redirect_url": _auth_redirect_url()} + except HTTPException: + raise except Exception as exc: raise HTTPException(status_code=500, detail=f"Could not generate OAuth URL: {exc}") @@ -539,6 +572,9 @@ async def scan_auto( from router import classify_image_type, ImageType img = Image.open(io.BytesIO(image_bytes)).convert("RGB") + + + image_type = classify_image_type(img) if image_type == ImageType.NOT_A_FISH: @@ -868,6 +904,7 @@ async def generate_gradcam( from inference import stream_a_model, stream_a_transforms, device from router import is_valid_fish_image + # Fish validity gate — same gate used by /api/v1/scan-auto is_fish, gate_score = is_valid_fish_image(img_pil) print(f" [GradCAM] Fish gate: {'PASS' if is_fish else 'FAIL'} (score={gate_score:.2%})") @@ -956,3 +993,4 @@ def _bwd_hook(_module, _grad_in, grad_out): register_routes(vendors_router, _db) app.include_router(vendors_router) + diff --git a/backend/requirements-base.txt b/backend/requirements-base.txt index 469f1da..ed127e2 100644 --- a/backend/requirements-base.txt +++ b/backend/requirements-base.txt @@ -6,4 +6,5 @@ numpy<2.0.0 python-dotenv>=1.0.0 python-multipart>=0.0.29 httpx>=0.27.0 -slowapi==0.1.9 # rate limiting for FastAPI; added for per-user scan endpoint throttling \ No newline at end of file +slowapi==0.1.9 # rate limiting for FastAPI; added for per-user scan endpoint throttling + \ No newline at end of file diff --git a/backend/test_auth.py b/backend/test_auth.py index 71e1dde..f21016d 100644 --- a/backend/test_auth.py +++ b/backend/test_auth.py @@ -13,10 +13,22 @@ Usage: python test_auth.py + +CI Mode (skip Turnstile verification): + Set SKIP_TURNSTILE_VERIFICATION=true to disable real Turnstile verification + in CI environments where Cloudflare Turnstile secrets are not available: + SKIP_TURNSTILE_VERIFICATION=true python test_auth.py + +New Tests (POST /api/v1/auth/login/google & Turnstile): + - test_google_oauth_post_without_turnstile() : Tests POST endpoint + - test_google_oauth_post_with_invalid_turnstile() : Tests Turnstile validation + - test_google_oauth_get_redirect() : Tests GET endpoint with Turnstile + - test_auth_login_rate_limiting() : Tests 5/minute rate limit enforcement """ import os import sys +import time import requests BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8000") @@ -32,6 +44,10 @@ SUPABASE_URL = os.environ.get("SUPABASE_URL", "https://mjklfhjnebidbsizulgr.supabase.co") SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "") +# ─── Turnstile Testing ──────────────────────────────────────────────────────── +# Set SKIP_TURNSTILE_VERIFICATION=true in CI to skip Turnstile tests (no real verification) +SKIP_TURNSTILE_VERIFICATION = os.environ.get("SKIP_TURNSTILE_VERIFICATION", "").lower() == "true" + def _color(code: int, text: str) -> str: return f"\033[{code}m{text}\033[0m" @@ -171,26 +187,122 @@ def test_scan_history(token: str): # ───────────────────────────────────────────────────────────────────────────── -# 5. Test: /api/v1/auth/login/google returns a redirect (302) +# 5. Test: /api/v1/auth/login/google (POST variant with Turnstile) # ───────────────────────────────────────────────────────────────────────────── -def test_google_oauth_redirect(): - section("Test 4 — GET /api/v1/auth/login/google (Redirects to Google)") +def test_google_oauth_post_without_turnstile(): + """Test POST /api/v1/auth/login/google without Turnstile token.""" + section("Test 4A — POST /api/v1/auth/login/google (No Turnstile)") + + payload = {} + r = requests.post(f"{BASE_URL}/api/v1/auth/login/google", json=payload, timeout=10) + + if r.status_code == 200: + data = r.json() + if "redirect_url" in data: + ok("POST /auth/login/google returns JSON with redirect_url ✓") + info(f"Redirect URL: {data['redirect_url'][:80]}...") + else: + fail(f"POST response missing 'redirect_url': {data}") + elif r.status_code == 400: + # Turnstile is required but token not provided + if "Turnstile token is required" in r.text: + ok("POST correctly requires Turnstile token when configured ✓") + else: + ok(f"POST returned 400 (Turnstile required): {r.text[:80]}") + elif r.status_code == 500: + info("POST /auth/login/google returned 500 — Supabase provider may not be configured") + else: + fail(f"POST /auth/login/google → unexpected {r.status_code}: {r.text}") + + +def test_google_oauth_post_with_invalid_turnstile(): + """Test POST /api/v1/auth/login/google with invalid Turnstile token.""" + section("Test 4B — POST /api/v1/auth/login/google (Invalid Turnstile Token)") + + if SKIP_TURNSTILE_VERIFICATION: + info("Skipping real Turnstile verification (SKIP_TURNSTILE_VERIFICATION=true)") + return + + payload = {"turnstile_token": "invalid_token_12345"} + r = requests.post(f"{BASE_URL}/api/v1/auth/login/google", json=payload, timeout=10) + + if r.status_code == 400: + ok("Invalid Turnstile token rejected with 400 ✓") + elif r.status_code == 502: + ok("Invalid Turnstile token returned 502 (service unavailable) ✓") + else: + info(f"Turnstile validation returned {r.status_code}: {r.text[:80]}") + +def test_google_oauth_get_redirect(): + section("Test 4C — GET /api/v1/auth/login/google (Redirects to Google)") + + # GET without Turnstile (should still work if not configured) r = requests.get(f"{BASE_URL}/api/v1/auth/login/google", allow_redirects=False, timeout=10) if r.status_code in (302, 307): location = r.headers.get("location", "") if "accounts.google.com" in location or "supabase" in location: - ok("Correctly redirects to OAuth provider ✓") + ok("GET correctly redirects to OAuth provider ✓") info(f"Redirect → {location[:80]}...") else: - ok(f"Got redirect to: {location[:80]}") + ok(f"GET got redirect to: {location[:80]}") + elif r.status_code == 400: + # Turnstile required + if "Turnstile token is required" in r.text: + ok("GET correctly requires Turnstile token when configured ✓") + else: + info(f"GET returned 400: {r.text[:80]}") elif r.status_code == 500: - info("Google OAuth redirect returned 500 — Supabase provider may not be configured yet") + info("GET /auth/login/google returned 500 — Supabase provider may not be configured") else: - fail(f"/auth/login/google → unexpected {r.status_code}: {r.text}") + fail(f"GET /auth/login/google → unexpected {r.status_code}: {r.text}") + + +def test_auth_login_rate_limiting(): + """Test that /api/v1/auth/login/google enforces rate limiting (5/minute).""" + section("Test 4D — Rate Limiting on /api/v1/auth/login/google (5/minute)") + + url_get = f"{BASE_URL}/api/v1/auth/login/google" + url_post = f"{BASE_URL}/api/v1/auth/login/google" + + # Fire 6 requests quickly (should exceed 5/minute limit) + attempts = 0 + rate_limit_hit = False + + for i in range(6): + if i < 3: + # Use GET for first 3 + r = requests.get(url_get, allow_redirects=False, timeout=10) + else: + # Use POST for next 3 + r = requests.post(url_post, json={}, timeout=10) + + attempts += 1 + + # Check for rate limit response (429) + if r.status_code == 429: + rate_limit_hit = True + ok(f"Rate limit enforced after {attempts} requests (429 Too Many Requests) ✓") + break + + # If we get 400/500/302/307, it's a valid response but not rate limited yet + if r.status_code in (200, 302, 307, 400, 500, 502): + info(f"Request {i+1} → {r.status_code}") + else: + info(f"Request {i+1} → {r.status_code}") + + if not rate_limit_hit and attempts >= 5: + info("Rate limiting may take time to accumulate; requests might not trigger immediately.") + info(f" (Made {attempts} requests without hitting 429 limit)") + + # Wait a bit and try again to confirm recovery + time.sleep(1) + r_recovery = requests.get(url_get, allow_redirects=False, timeout=10) + if r_recovery.status_code != 429: + info("✓ Rate limit counter appears to reset after brief wait") # ───────────────────────────────────────────────────────────────────────────── @@ -199,7 +311,7 @@ def test_google_oauth_redirect(): def test_public_vendors(): - section("Test 5 — GET /api/v1/vendors (Public Endpoint, No Auth)") + section("Test 6 — GET /api/v1/vendors (Public Endpoint, No Auth)") r = requests.get(f"{BASE_URL}/api/v1/vendors", timeout=10) if r.status_code == 200: @@ -219,6 +331,9 @@ def test_public_vendors(): print(_color(33, "\n FreshScan AI — Auth Integration Test Suite")) print(_color(33, f" Server: {BASE_URL}\n")) + if SKIP_TURNSTILE_VERIFICATION: + print(_color(33, " ⚠️ SKIP_TURNSTILE_VERIFICATION=true (running in CI mode)\n")) + # Resolve token token = TOKEN if not token: @@ -234,7 +349,10 @@ def test_public_vendors(): # Always run these test_unauthenticated_rejected() - test_google_oauth_redirect() + test_google_oauth_post_without_turnstile() + test_google_oauth_post_with_invalid_turnstile() + test_google_oauth_get_redirect() + test_auth_login_rate_limiting() test_public_vendors() # Only run with a real token diff --git a/backend/turnstile.py b/backend/turnstile.py new file mode 100644 index 0000000..73c2877 --- /dev/null +++ b/backend/turnstile.py @@ -0,0 +1,53 @@ +import os +import logging +from typing import Optional + +import httpx +from fastapi import HTTPException + +logger = logging.getLogger(__name__) + +TURNSTILE_SECRET_KEY = os.environ.get('TURNSTILE_SECRET_KEY', '') + +async def verify_turnstile_token( + turnstile_token: Optional[str], + remote_ip: Optional[str] = None, +) -> None: + if not TURNSTILE_SECRET_KEY: + raise HTTPException( + status_code=500, + detail='Turnstile secret key is not configured. Set TURNSTILE_SECRET_KEY.', + ) + + if not turnstile_token: + raise HTTPException(status_code=400, detail='Turnstile token is required.') + + payload = { + 'secret': TURNSTILE_SECRET_KEY, + 'response': turnstile_token, + } + if remote_ip: + payload['remoteip'] = remote_ip + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + 'https://challenges.cloudflare.com/turnstile/v0/siteverify', + data=payload, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + ) + response.raise_for_status() + data = response.json() + except Exception as exc: + logger.error(f'Turnstile verification failed: {exc}', exc_info=True) + raise HTTPException( + status_code=502, + detail='Service unavailable. Please try again later.', + ) + + if not data.get('success'): + errors = data.get('error-codes', []) + raise HTTPException( + status_code=400, + detail=f"Turnstile verification failed: {', '.join(errors) or 'invalid token'}", + ) diff --git a/package-lock.json b/package-lock.json index d6e7bfc..6d801e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2150,9 +2150,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2169,9 +2166,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2188,9 +2182,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2207,9 +2198,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2226,9 +2214,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2245,9 +2230,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2526,9 +2508,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2543,9 +2522,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2560,9 +2536,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2577,9 +2550,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2594,9 +2564,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2611,9 +2578,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2628,9 +2592,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2645,9 +2606,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2662,9 +2620,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2679,9 +2634,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2696,9 +2648,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2713,9 +2662,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2730,9 +2676,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2942,9 +2885,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2961,9 +2901,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2980,9 +2917,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2999,9 +2933,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5694,9 +5625,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5717,9 +5645,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5740,9 +5665,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5763,9 +5685,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/lib/api.ts b/src/lib/api.ts index a77e1cb..6696d23 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -95,7 +95,17 @@ export interface EdgeInferenceMeta { // ── API surface ─────────────────────────────────────────────────────────────── export const api = { - loginUrl: (): string => `${API_BASE}/api/v1/auth/login/google`, + loginUrl: async (turnstileToken?: string): Promise => { + if (turnstileToken) { + const response = await apiFetch<{ redirect_url: string }>('/api/v1/auth/login/google', { + method: 'POST', + body: JSON.stringify({ turnstile_token: turnstileToken }), + }); + return response.redirect_url; + } + + return `${API_BASE}/api/v1/auth/login/google`; + }, getMe: (): Promise => apiFetch('/api/v1/auth/me'), diff --git a/src/lib/useTurnstile.ts b/src/lib/useTurnstile.ts new file mode 100644 index 0000000..512cc65 --- /dev/null +++ b/src/lib/useTurnstile.ts @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from 'react'; + +declare global { + interface Window { + turnstile?: { + render: (container: HTMLElement, options: Record) => number; + execute: (widgetId: number) => void; + reset: (widgetId: number) => void; + }; + } +} + +const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + +async function loadTurnstileScript(): Promise { + if (window.turnstile) { + return; + } + + return new Promise((resolve, reject) => { + const existingScript = document.querySelector(`script[src="${SCRIPT_SRC}"]`); + if (existingScript) { + if ((existingScript as HTMLScriptElement).dataset.loaded === 'true') { + return resolve(); + } + existingScript.addEventListener('load', () => resolve()); + existingScript.addEventListener('error', () => reject(new Error('Failed to load Turnstile script.'))); + return; + } + + const script = document.createElement('script'); + script.src = SCRIPT_SRC; + script.async = true; + script.defer = true; + script.onload = () => { + script.dataset.loaded = 'true'; + resolve(); + }; + script.onerror = () => reject(new Error('Failed to load Turnstile script.')); + document.head.appendChild(script); + }); +} + +export default function useTurnstile(siteKey: string | undefined) { + const containerRef = useRef(null); + const widgetIdRef = useRef(null); + const pendingRef = useRef<{ + resolve: (token: string) => void; + reject: (error: Error) => void; + } | null>(null); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!siteKey) { + setReady(false); + return; + } + + let canceled = false; + + async function setup() { + try { + await loadTurnstileScript(); + if (canceled) return; + if (!window.turnstile || !containerRef.current) { + throw new Error('Turnstile is unavailable in this environment.'); + } + + if (widgetIdRef.current === null) { + widgetIdRef.current = window.turnstile.render(containerRef.current, { + sitekey: siteKey, + size: 'normal', + + callback: (token: string) => { + const pending = pendingRef.current; + pending?.resolve(token); + pendingRef.current = null; + }, + + 'error-callback': () => { + const pending = pendingRef.current; + pending?.reject(new Error('Turnstile verification failed.')); + pendingRef.current = null; + }, + + 'expired-callback': () => { + const pending = pendingRef.current; + pending?.reject( + new Error('Turnstile token expired. Please verify again.') + ); + pendingRef.current = null; + }, + }); + } + setReady(true); + } catch (err) { + if (!canceled) { + const loadError = err instanceof Error ? err : new Error('Unknown Turnstile load error'); + setError(loadError); + setReady(false); + } + } + } + + setup(); + + return () => { + canceled = true; + }; + }, [siteKey]); + + const execute = async (): Promise => { + if (!siteKey) { + throw new Error('Turnstile site key is not configured.'); + } + if (error) { + throw error; + } + if (!ready || widgetIdRef.current === null) { + throw new Error('Turnstile is not ready yet. Please wait and try again.'); + } + + return new Promise((resolve, reject) => { + pendingRef.current = { resolve, reject }; + try { + window.turnstile?.execute(widgetIdRef.current as number); + } catch (exc) { + pendingRef.current = null; + reject(exc instanceof Error ? exc : new Error('Turnstile execution failed.')); + } + }); + }; + + return { containerRef, ready, execute, error }; +} diff --git a/src/pages/AuthPage.tsx b/src/pages/AuthPage.tsx index 616e2ec..78671a6 100644 --- a/src/pages/AuthPage.tsx +++ b/src/pages/AuthPage.tsx @@ -3,16 +3,19 @@ import { useNavigate } from 'react-router-dom'; import { usePostHog } from 'posthog-js/react'; import StatusTerminal from '../components/StatusTerminal'; import { api, setToken, isAuthenticated } from '../lib/api'; +import useTurnstile from '../lib/useTurnstile'; // Bypass token must match DEV_BYPASS_TOKEN in backend/.env const DEV_BYPASS_TOKEN = 'dev-local-bypass-token'; const IS_DEV_MODE = import.meta.env.VITE_DEV_MODE === 'true'; +const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined; export default function AuthPage() { const navigate = useNavigate(); const posthog = usePostHog(); const [status, setStatus] = useState<'idle' | 'processing' | 'error'>('idle'); const [errorMsg, setErrorMsg] = useState(''); + const { containerRef, ready: turnstileReady, execute: executeTurnstile, error: turnstileError } = useTurnstile(TURNSTILE_SITE_KEY); // Handle redirect from backend OAuth callback useEffect(() => { @@ -40,21 +43,35 @@ export default function AuthPage() { } }, [navigate, posthog]); - const handleGoogleLogin = () => { + const handleGoogleLogin = async () => { try { setStatus('processing'); - const loginUrl = api.loginUrl(); - + let turnstileToken: string | undefined; + + if (TURNSTILE_SITE_KEY) { + if (!turnstileReady) { + throw new Error('Turnstile is still loading. Please wait and try again.'); + } + if (turnstileError) { + throw turnstileError; + } + turnstileToken = await executeTurnstile(); + } + + const loginUrl = await api.loginUrl(turnstileToken); if (!loginUrl) { - throw new Error("Login URL configuration missing"); + throw new Error('Login URL configuration missing'); } - - // Force full browser navigation for OAuth + window.location.href = loginUrl; } catch (err) { setStatus('error'); - setErrorMsg('Could not initiate Google Login. Please check your network connection.'); - console.error("Auth initiation failed:", err); + setErrorMsg( + err instanceof Error + ? err.message + : 'Could not initiate Google Login. Please check your network connection.' + ); + console.error('Auth initiation failed:', err); } }; @@ -102,12 +119,27 @@ export default function AuthPage() { Sign in to view your live Trust Map and sync biomarker data across devices.

)} + + {TURNSTILE_SITE_KEY && !turnstileReady && ( +

+ Loading verification challenge... +

+ )} + + {turnstileError && ( +

+ {turnstileError.message} +

+ )}
+ +
); } \ No newline at end of file