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