ccr-platform / backend /app /auth_google.py
devaanand's picture
fix(auth): trim whitespace from CCR_APP_URL to prevent sign-in issues
3fde616
Raw
History Blame Contribute Delete
4 kB
"""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}