Spaces:
Sleeping
Sleeping
| """Google sign-in via Supabase Auth (server-side PKCE flow). | |
| Feature-flagged: everything here is inert until SUPABASE_URL and | |
| SUPABASE_ANON_KEY are set, so local dev and tests run unchanged without any | |
| Supabase project. When configured, the flow is: | |
| 1. GET /api/auth/google/login -> redirect to Supabase's Google authorize | |
| URL with a PKCE challenge; the verifier rides in a short-lived signed | |
| cookie (never stored server-side). | |
| 2. Google -> Supabase -> GET /api/auth/google/callback?code=... | |
| 3. The backend exchanges code+verifier for the Supabase user (stdlib | |
| urllib - no new dependencies), finds-or-creates a local User row by | |
| email, and issues OUR normal session cookie (auth.py). | |
| Design consequence: Supabase verifies identity at sign-in time only; the | |
| session, tiers, and ownership model are exactly the same as email/password | |
| accounts. Google users have an empty password_hash and cannot password-login | |
| (a clear message says to use Google). Because users are re-created on next | |
| sign-in by email, an ephemeral-disk dev instance losing its SQLite file is a | |
| nuisance, not a lockout. | |
| No frontend SDK: the button is a plain link, keeping the react+react-dom-only | |
| dependency rule intact. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import hashlib | |
| import json | |
| import os | |
| import secrets | |
| import urllib.error | |
| import urllib.parse | |
| import urllib.request | |
| VERIFIER_COOKIE = "ccr_pkce" | |
| VERIFIER_TTL_SECONDS = 600 | |
| def configured() -> bool: | |
| return bool(os.environ.get("SUPABASE_URL") and os.environ.get("SUPABASE_ANON_KEY")) | |
| def _supabase_url() -> str: | |
| return os.environ["SUPABASE_URL"].rstrip("/") | |
| def app_url() -> str: | |
| """Public base URL of THIS app (redirect target). Local default matches | |
| the dev server; deployments set CCR_APP_URL. | |
| strip() before rstrip(): a value pasted into a hosting dashboard can carry | |
| a trailing newline. Untrimmed it rides into redirect_to as %0A, so the URL | |
| no longer matches the Supabase redirect allow list AND browsers flag the | |
| link (a raw newline in a URL parameter reads as header injection). Seen in | |
| production 2026-08-17. | |
| """ | |
| return os.environ.get("CCR_APP_URL", "http://127.0.0.1:8000").strip().rstrip("/") | |
| def begin() -> tuple[str, str]: | |
| """Return (authorize_url, code_verifier).""" | |
| verifier = secrets.token_urlsafe(64) | |
| challenge = ( | |
| base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()) | |
| .decode() | |
| .rstrip("=") | |
| ) | |
| params = urllib.parse.urlencode( | |
| { | |
| "provider": "google", | |
| "redirect_to": f"{app_url()}/api/auth/google/callback", | |
| "code_challenge": challenge, | |
| "code_challenge_method": "s256", | |
| } | |
| ) | |
| return f"{_supabase_url()}/auth/v1/authorize?{params}", verifier | |
| def exchange(code: str, verifier: str) -> dict: | |
| """Exchange the PKCE code for the Supabase user. Returns {email, name}. | |
| Raises ValueError with a user-safe message on any failure.""" | |
| body = json.dumps({"auth_code": code, "code_verifier": verifier}).encode() | |
| req = urllib.request.Request( | |
| f"{_supabase_url()}/auth/v1/token?grant_type=pkce", | |
| data=body, | |
| headers={ | |
| "apikey": os.environ["SUPABASE_ANON_KEY"], | |
| "Content-Type": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(req, timeout=15) as resp: | |
| payload = json.load(resp) | |
| except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as exc: | |
| raise ValueError("Google sign-in could not be completed. Please try again.") from exc | |
| user = payload.get("user") or {} | |
| email = (user.get("email") or "").strip().lower() | |
| if not email: | |
| raise ValueError("Google sign-in returned no email address.") | |
| meta = user.get("user_metadata") or {} | |
| name = (meta.get("full_name") or meta.get("name") or email.split("@")[0]).strip() | |
| return {"email": email, "name": name} | |