emo-online-api / emo /backend /google_auth.py
Emo Online
Deploy Emo API
dd87944
Raw
History Blame Contribute Delete
4.64 kB
"""Google OAuth 2.0 — connexion self-hosted (sans Emergent)."""
from __future__ import annotations
import ssl_fix # noqa: F401
import base64
import json
import os
from urllib.parse import urlencode
import httpx
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
def has_client_id() -> bool:
return bool(os.environ.get("GOOGLE_CLIENT_ID", "").strip())
def is_configured() -> bool:
return has_client_id() and bool(os.environ.get("GOOGLE_CLIENT_SECRET", "").strip())
def redirect_uri() -> str:
explicit = os.environ.get("GOOGLE_REDIRECT_URI", "").strip()
if explicit:
return explicit
base = os.environ.get("EMO_PUBLIC_BACKEND_URL", "https://xroxx-emo-online-api.hf.space").rstrip("/")
return f"{base}/api/auth/google/callback"
def normalize_frontend_callback(url: str) -> str:
url = (url or "").strip().rstrip("/")
if not url:
return "http://127.0.0.1:3000/auth/google/callback"
if url.endswith("/auth/google/callback"):
return url
if url.endswith("/chat"):
return url[: -len("/chat")] + "/auth/google/callback"
if url.endswith("/login"):
return url[: -len("/login")] + "/auth/google/callback"
return f"{url}/auth/google/callback"
def build_login_url(frontend_redirect: str, desktop: bool = False) -> str:
state = base64.urlsafe_b64encode(
json.dumps({"redirect": frontend_redirect}).encode()
).decode().rstrip("=")
params = {
"client_id": os.environ["GOOGLE_CLIENT_ID"],
"redirect_uri": redirect_uri(),
"response_type": "code",
"scope": "openid email profile",
"access_type": "online",
"state": state,
}
if desktop:
pass
else:
params["prompt"] = "select_account"
return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
def parse_state(state: str) -> str:
try:
pad = "=" * (-len(state) % 4)
data = json.loads(base64.urlsafe_b64decode(state + pad).decode())
return normalize_frontend_callback(data.get("redirect") or "")
except Exception:
return "http://127.0.0.1:3000/auth/google/callback"
def map_oauth_error(error: str, detail: str = "") -> str:
if error == "access_denied":
return "access_denied"
if "redirect_uri_mismatch" in detail:
return "redirect_uri_mismatch"
if "invalid_client" in detail:
return "invalid_client"
return error or "google_auth_failed"
async def exchange_code(code: str) -> dict:
async with httpx.AsyncClient(timeout=20) as client:
token_resp = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": os.environ["GOOGLE_CLIENT_ID"],
"client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
"redirect_uri": redirect_uri(),
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
detail = token_resp.text[:300]
if "invalid_client" in detail:
raise ValueError("invalid_client")
if "redirect_uri_mismatch" in detail:
raise ValueError("redirect_uri_mismatch")
raise ValueError(f"Token Google invalide: {detail}")
access_token = token_resp.json().get("access_token")
if not access_token:
raise ValueError("Pas de access_token Google")
user_resp = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if user_resp.status_code != 200:
raise ValueError("Impossible de lire le profil Google")
return user_resp.json()
async def verify_id_token(id_token: str) -> dict:
client_id = os.environ.get("GOOGLE_CLIENT_ID", "").strip()
if not client_id:
raise ValueError("missing_client_id")
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
"https://oauth2.googleapis.com/tokeninfo",
params={"id_token": id_token},
)
if resp.status_code != 200:
raise ValueError("invalid_id_token")
data = resp.json()
aud = data.get("aud") or data.get("azp")
if aud != client_id:
raise ValueError("wrong_audience")
if not data.get("email"):
raise ValueError("no_email")
if str(data.get("email_verified", "true")).lower() == "false":
raise ValueError("email_not_verified")
return data