# ----------------------------------------------------------------------------- # Food-Ka — Agrégateur de produits d'épicerie (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com) # # Identité PARTAGÉE Groupe-Ka : le hub (https://www.groupe-ka.com) authentifie # l'utilisateur et le renvoie ici avec un JWT HS256 signé du secret partagé # KA_SSO_SECRET. Le profil (ka_id « ka-0123456789 », courriel, nom, photo) # est LE MÊME sur toutes les plateformes du groupe. # # Session locale : JWT HS256 maison (hmac/base64, aucune dépendance) signé # avec SESSION_SECRET, cookie httponly 30 jours. # Config .env : KA_SSO_SECRET, KA_HUB_URL, FOODKA_BASE_URL, SESSION_SECRET. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import hashlib import hmac import json import os import time import urllib.parse from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db from .hubprofile import fetch_hub_profile, to_epoch router = APIRouter(prefix="/api/auth") KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") # base publique de CETTE app (l'URL de rappel en découle) BASE_URL = os.environ.get("FOODKA_BASE_URL", "http://localhost:8097").rstrip("/") SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256( (KA_SSO_SECRET or "foodka-dev").encode()).hexdigest() COOKIE = "foodka_session" SESSION_DAYS = 30 # -- JWT HS256 minimal (aucune dépendance) ----------------------------------- def _b64(d: bytes) -> str: return base64.urlsafe_b64encode(d).rstrip(b"=").decode() def _unb64(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def jwt_encode(payload: dict) -> str: head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) body = _b64(json.dumps(payload, separators=(",", ":")).encode()) sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) return f"{head}.{body}.{sig}" def jwt_decode(token: str) -> dict | None: try: head, body, sig = token.split(".") good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None payload = json.loads(_unb64(body)) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None def _ka_verify(token: str) -> dict | None: """Vérifie le JWT HS256 émis par le hub KA (stdlib seulement) : signature, alg, iss, aud, exp.""" if not KA_SSO_SECRET: return None try: head, body, sig = token.split(".") good = _b64(hmac.new(KA_SSO_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None if json.loads(_unb64(head)).get("alg") != "HS256": return None claims = json.loads(_unb64(body)) if claims.get("iss") != KA_HUB_URL: return None if claims.get("aud") != "food-ka": return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- table users (clé = KA-ID du groupe, identique sur toutes les apps) ------- def _ensure_table(con) -> None: con.execute("""CREATE TABLE IF NOT EXISTS users ( ka_id TEXT PRIMARY KEY, -- identifiant membre Groupe KA (« ka-0123456789 ») email TEXT, name TEXT, picture TEXT, provider TEXT, created REAL, last_login REAL )""") def _upsert_user(info: dict) -> dict: con = db.connect() try: _ensure_table(con) now = time.time() con.execute( "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)" " VALUES (?,?,?,?,?,?,?)" " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email," " name=excluded.name, picture=excluded.picture," " provider=excluded.provider, last_login=excluded.last_login", (info["ka_id"], info.get("email", ""), info.get("name", ""), info.get("picture", ""), info.get("provider", ""), now, now)) con.commit() row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?", (info["ka_id"],)).fetchone() finally: con.close() return {"ka_id": info["ka_id"], "email": info.get("email", ""), "name": info.get("name", ""), "picture": info.get("picture", ""), "provider": info.get("provider", ""), "created_at": row["created"] if row else now, "last_login": row["last_login"] if row else now} def current_user(request: Request) -> dict | None: payload = jwt_decode(request.cookies.get(COOKIE, "")) return payload.get("user") if payload else None # -- routes -------------------------------------------------------------------- @router.get("/ka/login") def ka_login(next: str = "/"): """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe.""" if not KA_SSO_SECRET: return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, status_code=503) state = jwt_encode({"next": next[:200], "exp": time.time() + 600}) params = { "client_id": "food-ka", "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}") @router.get("/ka/callback") def ka_callback(ka_token: str = "", state: str = ""): """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session.""" st = jwt_decode(state) or {} dest = st.get("next") or "/" if not dest.startswith("/"): dest = "/" claims = _ka_verify(ka_token) if ka_token else None if not st or claims is None: return RedirectResponse("/?auth=echec") # clé stable = KA-ID du groupe (créé par le hub, identique partout) ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") user = _upsert_user({ "ka_id": ka_id, "email": claims.get("email", ""), "name": claims.get("name", ""), "picture": claims.get("picture") or "", "provider": claims.get("provider") or "ka-id", }) session = jwt_encode({"user": user, "iss": "groupe-ka", "exp": time.time() + SESSION_DAYS * 86400}) resp = RedirectResponse(dest) resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, samesite="lax", secure=BASE_URL.startswith("https"), path="/") return resp @router.get("/me") def me(request: Request): """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si la connexion KA ID est configurée (le frontend masque le bouton sinon). Enrichi avec le profil du HUB Groupe KA (bio, ville, emploi, entreprise, âge, site web, réseaux sociaux, statut, profil public) — le hub est LA source de vérité, `profile_source` = « groupe-ka » quand il répond.""" user = current_user(request) if user: user = dict(user) hub = fetch_hub_profile(user.get("ka_id") or "") if hub: for k in ("name", "email", "picture"): # le hub prime if hub.get(k): user[k] = hub[k] user.update({ "bio": hub.get("bio") or "", "city": hub.get("city") or "", "job_title": hub.get("job_title") or "", "company": hub.get("company") or "", "age": hub.get("age"), "website": hub.get("website") or "", "socials": hub.get("socials") or {}, "role_label": hub.get("role_label") or "", "public": bool(hub.get("public")), "public_url": hub.get("public_url") or "", "profile_source": "groupe-ka", }) created = to_epoch(hub.get("created_at")) if created: user["created_at"] = created else: user["profile_source"] = "local" # hub injoignable ou 404 return {"user": user, "enabled": bool(KA_SSO_SECRET)} @router.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp