Food-Ka — agrégateur de produits d'épicerie du Québec — www.food-ka.com
Python 53.9%
TypeScript 24%
CSS 14.9%
JavaScript 5.8%
HTML 1.4%
1# -----------------------------------------------------------------------------2# Food-Ka — Agrégateur de produits d'épicerie (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com)5#6# Identité PARTAGÉE Groupe-Ka : le hub (https://www.groupe-ka.com) authentifie7# l'utilisateur et le renvoie ici avec un JWT HS256 signé du secret partagé8# KA_SSO_SECRET. Le profil (ka_id « ka-0123456789 », courriel, nom, photo)9# est LE MÊME sur toutes les plateformes du groupe.10#11# Session locale : JWT HS256 maison (hmac/base64, aucune dépendance) signé12# avec SESSION_SECRET, cookie httponly 30 jours.13# Config .env : KA_SSO_SECRET, KA_HUB_URL, FOODKA_BASE_URL, SESSION_SECRET.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import base6418import hashlib19import hmac20import json21import os22import time23import urllib.parse2425from fastapi import APIRouter, Request26from fastapi.responses import JSONResponse, RedirectResponse2728from . import db29from .hubprofile import fetch_hub_profile, to_epoch3031router = APIRouter(prefix="/api/auth")3233KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")34KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")35# base publique de CETTE app (l'URL de rappel en découle)36BASE_URL = os.environ.get("FOODKA_BASE_URL", "http://localhost:8097").rstrip("/")37SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(38 (KA_SSO_SECRET or "foodka-dev").encode()).hexdigest()39COOKIE = "foodka_session"40SESSION_DAYS = 30414243# -- JWT HS256 minimal (aucune dépendance) -----------------------------------44def _b64(d: bytes) -> str:45 return base64.urlsafe_b64encode(d).rstrip(b"=").decode()464748def _unb64(s: str) -> bytes:49 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))505152def jwt_encode(payload: dict) -> str:53 head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())54 body = _b64(json.dumps(payload, separators=(",", ":")).encode())55 sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())56 return f"{head}.{body}.{sig}"575859def jwt_decode(token: str) -> dict | None:60 try:61 head, body, sig = token.split(".")62 good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())63 if not hmac.compare_digest(sig, good):64 return None65 payload = json.loads(_unb64(body))66 if payload.get("exp", 0) < time.time():67 return None68 return payload69 except Exception:70 return None717273def _ka_verify(token: str) -> dict | None:74 """Vérifie le JWT HS256 émis par le hub KA (stdlib seulement) :75 signature, alg, iss, aud, exp."""76 if not KA_SSO_SECRET:77 return None78 try:79 head, body, sig = token.split(".")80 good = _b64(hmac.new(KA_SSO_SECRET.encode(),81 f"{head}.{body}".encode(), hashlib.sha256).digest())82 if not hmac.compare_digest(sig, good):83 return None84 if json.loads(_unb64(head)).get("alg") != "HS256":85 return None86 claims = json.loads(_unb64(body))87 if claims.get("iss") != KA_HUB_URL:88 return None89 if claims.get("aud") != "food-ka":90 return None91 if claims.get("exp", 0) < time.time():92 return None93 return claims94 except Exception:95 return None969798# -- table users (clé = KA-ID du groupe, identique sur toutes les apps) -------99def _ensure_table(con) -> None:100 con.execute("""CREATE TABLE IF NOT EXISTS users (101 ka_id TEXT PRIMARY KEY, -- identifiant membre Groupe KA (« ka-0123456789 »)102 email TEXT,103 name TEXT,104 picture TEXT,105 provider TEXT,106 created REAL,107 last_login REAL108 )""")109110111def _upsert_user(info: dict) -> dict:112 con = db.connect()113 try:114 _ensure_table(con)115 now = time.time()116 con.execute(117 "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)"118 " VALUES (?,?,?,?,?,?,?)"119 " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email,"120 " name=excluded.name, picture=excluded.picture,"121 " provider=excluded.provider, last_login=excluded.last_login",122 (info["ka_id"], info.get("email", ""), info.get("name", ""),123 info.get("picture", ""), info.get("provider", ""), now, now))124 con.commit()125 row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?",126 (info["ka_id"],)).fetchone()127 finally:128 con.close()129 return {"ka_id": info["ka_id"], "email": info.get("email", ""),130 "name": info.get("name", ""), "picture": info.get("picture", ""),131 "provider": info.get("provider", ""),132 "created_at": row["created"] if row else now,133 "last_login": row["last_login"] if row else now}134135136def current_user(request: Request) -> dict | None:137 payload = jwt_decode(request.cookies.get(COOKIE, ""))138 return payload.get("user") if payload else None139140141# -- routes --------------------------------------------------------------------142@router.get("/ka/login")143def ka_login(next: str = "/"):144 """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe."""145 if not KA_SSO_SECRET:146 return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},147 status_code=503)148 state = jwt_encode({"next": next[:200], "exp": time.time() + 600})149 params = {150 "client_id": "food-ka",151 "redirect_uri": f"{BASE_URL}/api/auth/ka/callback",152 "state": state,153 }154 return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")155156157@router.get("/ka/callback")158def ka_callback(ka_token: str = "", state: str = ""):159 """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""160 st = jwt_decode(state) or {}161 dest = st.get("next") or "/"162 if not dest.startswith("/"):163 dest = "/"164 claims = _ka_verify(ka_token) if ka_token else None165 if not st or claims is None:166 return RedirectResponse("/?auth=echec")167 # clé stable = KA-ID du groupe (créé par le hub, identique partout)168 ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")169 user = _upsert_user({170 "ka_id": ka_id,171 "email": claims.get("email", ""),172 "name": claims.get("name", ""),173 "picture": claims.get("picture") or "",174 "provider": claims.get("provider") or "ka-id",175 })176 session = jwt_encode({"user": user, "iss": "groupe-ka",177 "exp": time.time() + SESSION_DAYS * 86400})178 resp = RedirectResponse(dest)179 resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,180 samesite="lax", secure=BASE_URL.startswith("https"), path="/")181 return resp182183184@router.get("/me")185def me(request: Request):186 """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si187 la connexion KA ID est configurée (le frontend masque le bouton sinon).188 Enrichi avec le profil du HUB Groupe KA (bio, ville, emploi, entreprise,189 âge, site web, réseaux sociaux, statut, profil public) — le hub est LA190 source de vérité, `profile_source` = « groupe-ka » quand il répond."""191 user = current_user(request)192 if user:193 user = dict(user)194 hub = fetch_hub_profile(user.get("ka_id") or "")195 if hub:196 for k in ("name", "email", "picture"): # le hub prime197 if hub.get(k):198 user[k] = hub[k]199 user.update({200 "bio": hub.get("bio") or "",201 "city": hub.get("city") or "",202 "job_title": hub.get("job_title") or "",203 "company": hub.get("company") or "",204 "age": hub.get("age"),205 "website": hub.get("website") or "",206 "socials": hub.get("socials") or {},207 "role_label": hub.get("role_label") or "",208 "public": bool(hub.get("public")),209 "public_url": hub.get("public_url") or "",210 "profile_source": "groupe-ka",211 })212 created = to_epoch(hub.get("created_at"))213 if created:214 user["created_at"] = created215 else:216 user["profile_source"] = "local" # hub injoignable ou 404217 return {"user": user, "enabled": bool(KA_SSO_SECRET)}218219220@router.post("/logout")221def logout():222 resp = JSONResponse({"ok": True})223 resp.delete_cookie(COOKIE, path="/")224 return resp225