Agrégateur de produits québécois — www.fabri-ka.com
Python 38.4%
HTML 30.3%
TypeScript 17.2%
CSS 11%
JavaScript 3.2%
1# -----------------------------------------------------------------------------2# Fabri-Ka — Agrégateur de produits québécois3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com)5#6# Flot : /api/auth/ka/login redirige vers {KA_HUB_URL}/sso/authorize7# (client_id=fabri-ka) ; le hub revient sur /api/auth/ka/callback avec un8# ka_token = JWT HS256 signé du secret partagé KA_SSO_SECRET. On vérifie9# signature + alg + iss + aud + exp (stdlib hmac/hashlib/base64/json —10# AUCUNE dépendance), on upsert l'utilisateur (clé = ka_id, LE MÊME11# identifiant membre sur toutes les plateformes du groupe), puis on pose12# une session locale : cookie httpOnly signé HMAC (SESSION_SECRET), 30 jours.13#14# Config .env : KA_SSO_SECRET, KA_HUB_URL, FABRIKA_BASE_URL, SESSION_SECRET.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import base6419import hashlib20import hmac21import json22import os23import time24import urllib.parse2526from fastapi import APIRouter, Request27from fastapi.responses import JSONResponse, RedirectResponse2829from . import db30from .hubprofile import fetch_hub_profile3132router = APIRouter(prefix="/api/auth")3334KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")35KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "")36KA_CLIENT_ID = "fabri-ka"37# base publique de CETTE app (l'URL de rappel en découle)38BASE_URL = os.environ.get("FABRIKA_BASE_URL", "https://www.fabri-ka.com").rstrip("/")39REDIRECT_URI = f"{BASE_URL}/api/auth/ka/callback"40SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256(41 (KA_SSO_SECRET or "fabrika-dev").encode()).hexdigest()42COOKIE = "fabrika_session"43SESSION_DAYS = 30444546# -- JWT HS256 minimal (aucune dépendance) -----------------------------------47def _b64(d: bytes) -> str:48 return base64.urlsafe_b64encode(d).rstrip(b"=").decode()495051def _unb64(s: str) -> bytes:52 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))535455def jwt_encode(payload: dict) -> str:56 """JWT HS256 local (state anti-CSRF + session), signé avec SESSION_SECRET."""57 head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())58 body = _b64(json.dumps(payload, separators=(",", ":")).encode())59 sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())60 return f"{head}.{body}.{sig}"616263def jwt_decode(token: str) -> dict | None:64 try:65 head, body, sig = token.split(".")66 good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())67 if not hmac.compare_digest(sig, good):68 return None69 payload = json.loads(_unb64(body))70 if payload.get("exp", 0) < time.time():71 return None72 return payload73 except Exception:74 return None757677def _ka_verify(token: str) -> dict | None:78 """Vérifie le JWT HS256 émis par le hub KA : signature (secret partagé),79 alg, iss, aud, exp — stdlib seulement."""80 if not KA_SSO_SECRET:81 return None82 try:83 head, body, sig = token.split(".")84 good = _b64(hmac.new(KA_SSO_SECRET.encode(),85 f"{head}.{body}".encode(), hashlib.sha256).digest())86 if not hmac.compare_digest(sig, good):87 return None88 if json.loads(_unb64(head)).get("alg") != "HS256":89 return None90 claims = json.loads(_unb64(body))91 if claims.get("iss") != KA_HUB_URL:92 return None93 if claims.get("aud") != KA_CLIENT_ID:94 return None95 if claims.get("exp", 0) < time.time():96 return None97 return claims98 except Exception:99 return None100101102# -- table users (clé = ka_id, créée à la volée) ------------------------------103def _ensure_table(con) -> None:104 con.execute("""CREATE TABLE IF NOT EXISTS users (105 ka_id TEXT PRIMARY KEY, -- identifiant membre Groupe KA (partout le même)106 email TEXT,107 name TEXT,108 picture TEXT,109 provider TEXT,110 created REAL,111 last_login REAL112 )""")113114115def _upsert_user(claims: dict) -> dict:116 ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}")117 con = db.connect()118 try:119 _ensure_table(con)120 now = time.time()121 con.execute(122 "INSERT INTO users (ka_id, email, name, picture, provider, created, last_login)"123 " VALUES (?,?,?,?,?,?,?)"124 " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email,"125 " name=excluded.name, picture=excluded.picture,"126 " provider=excluded.provider, last_login=excluded.last_login",127 (ka_id, claims.get("email", ""), claims.get("name", ""),128 claims.get("picture") or "", claims.get("provider") or "ka-id", now, now))129 con.commit()130 finally:131 con.close()132 return {"ka_id": ka_id, "email": claims.get("email", ""),133 "name": claims.get("name", ""), "picture": claims.get("picture") or "",134 "provider": claims.get("provider") or "ka-id"}135136137def current_user(request: Request) -> dict | None:138 payload = jwt_decode(request.cookies.get(COOKIE, ""))139 return payload.get("user") if payload else None140141142# -- routes --------------------------------------------------------------------143@router.get("/ka/login")144def ka_login(next: str = "/"):145 """Redirige vers le hub KA ID (groupe-ka.com) — SSO du Groupe KA."""146 if not KA_SSO_SECRET:147 return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"},148 status_code=503)149 state = jwt_encode({"next": next[:200], "exp": time.time() + 600})150 params = {151 "client_id": KA_CLIENT_ID,152 "redirect_uri": REDIRECT_URI,153 "state": state,154 }155 return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}")156157158@router.get("/ka/callback")159def ka_callback(ka_token: str = "", state: str = ""):160 """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session."""161 st = jwt_decode(state) or {}162 dest = st.get("next") or "/"163 if not dest.startswith("/"):164 dest = "/"165 claims = _ka_verify(ka_token) if ka_token else None166 if not st or claims is None:167 return RedirectResponse("/?auth=echec")168 user = _upsert_user(claims)169 session = jwt_encode({"user": user, "iss": "fabri-ka",170 "exp": time.time() + SESSION_DAYS * 86400})171 resp = RedirectResponse(dest)172 resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True,173 samesite="lax", secure=BASE_URL.startswith("https"), path="/")174 return resp175176177@router.get("/me")178def me(request: Request):179 """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si180 la connexion KA ID est configurée (le frontend masque le bouton sinon).181 Le profil est enrichi depuis le HUB Groupe KA (bio, ville, emploi,182 entreprise, âge, site web, réseaux sociaux, statut, profil public) —183 source de vérité : groupe-ka.com ; si le hub est injoignable, on retombe184 sur les seules données locales (profile_source='local')."""185 user = current_user(request)186 if user and user.get("ka_id"):187 con = db.connect()188 try:189 _ensure_table(con)190 row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?",191 (user["ka_id"],)).fetchone()192 if row:193 user["created"] = row["created"]194 user["last_login"] = row["last_login"]195 finally:196 con.close()197 hub = fetch_hub_profile(user["ka_id"])198 if hub:199 user["profile_source"] = "groupe-ka"200 # champs affichés tels quels sur /profil (identiques au hub)201 user["bio"] = hub.get("bio") or ""202 user["city"] = hub.get("city") or ""203 user["job_title"] = hub.get("job_title") or ""204 user["company"] = hub.get("company") or ""205 user["age"] = hub.get("age")206 user["website"] = hub.get("website") or ""207 user["socials"] = hub.get("socials") or {}208 user["role_label"] = hub.get("role_label") or ""209 user["public"] = bool(hub.get("public"))210 user["public_url"] = hub.get("public_url") or ""211 # le hub fait aussi foi pour l'identité (nom, photo)212 if hub.get("name"):213 user["name"] = hub["name"]214 if hub.get("picture"):215 user["picture"] = hub["picture"]216 else:217 user["profile_source"] = "local"218 return {"user": user, "enabled": bool(KA_SSO_SECRET)}219220221@router.post("/logout")222def logout():223 resp = JSONResponse({"ok": True})224 resp.delete_cookie(COOKIE, path="/")225 return resp226