# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com) # # Flot : /api/auth/ka/login redirige vers {KA_HUB_URL}/sso/authorize # (client_id=fabri-ka) ; le hub revient sur /api/auth/ka/callback avec un # ka_token = JWT HS256 signé du secret partagé KA_SSO_SECRET. On vérifie # signature + alg + iss + aud + exp (stdlib hmac/hashlib/base64/json — # AUCUNE dépendance), on upsert l'utilisateur (clé = ka_id, LE MÊME # identifiant membre sur toutes les plateformes du groupe), puis on pose # une session locale : cookie httpOnly signé HMAC (SESSION_SECRET), 30 jours. # # Config .env : KA_SSO_SECRET, KA_HUB_URL, FABRIKA_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 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", "") KA_CLIENT_ID = "fabri-ka" # base publique de CETTE app (l'URL de rappel en découle) BASE_URL = os.environ.get("FABRIKA_BASE_URL", "https://www.fabri-ka.com").rstrip("/") REDIRECT_URI = f"{BASE_URL}/api/auth/ka/callback" SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256( (KA_SSO_SECRET or "fabrika-dev").encode()).hexdigest() COOKIE = "fabrika_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: """JWT HS256 local (state anti-CSRF + session), signé avec SESSION_SECRET.""" 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 : signature (secret partagé), alg, iss, aud, exp — stdlib seulement.""" 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") != KA_CLIENT_ID: return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- table users (clé = ka_id, créée à la volée) ------------------------------ def _ensure_table(con) -> None: con.execute("""CREATE TABLE IF NOT EXISTS users ( ka_id TEXT PRIMARY KEY, -- identifiant membre Groupe KA (partout le même) email TEXT, name TEXT, picture TEXT, provider TEXT, created REAL, last_login REAL )""") def _upsert_user(claims: dict) -> dict: ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") 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", (ka_id, claims.get("email", ""), claims.get("name", ""), claims.get("picture") or "", claims.get("provider") or "ka-id", now, now)) con.commit() finally: con.close() return {"ka_id": ka_id, "email": claims.get("email", ""), "name": claims.get("name", ""), "picture": claims.get("picture") or "", "provider": claims.get("provider") or "ka-id"} 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 KA.""" 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": KA_CLIENT_ID, "redirect_uri": REDIRECT_URI, "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") user = _upsert_user(claims) session = jwt_encode({"user": user, "iss": "fabri-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). Le profil est enrichi depuis le HUB Groupe KA (bio, ville, emploi, entreprise, âge, site web, réseaux sociaux, statut, profil public) — source de vérité : groupe-ka.com ; si le hub est injoignable, on retombe sur les seules données locales (profile_source='local').""" user = current_user(request) if user and user.get("ka_id"): con = db.connect() try: _ensure_table(con) row = con.execute("SELECT created, last_login FROM users WHERE ka_id=?", (user["ka_id"],)).fetchone() if row: user["created"] = row["created"] user["last_login"] = row["last_login"] finally: con.close() hub = fetch_hub_profile(user["ka_id"]) if hub: user["profile_source"] = "groupe-ka" # champs affichés tels quels sur /profil (identiques au hub) user["bio"] = hub.get("bio") or "" user["city"] = hub.get("city") or "" user["job_title"] = hub.get("job_title") or "" user["company"] = hub.get("company") or "" user["age"] = hub.get("age") user["website"] = hub.get("website") or "" user["socials"] = hub.get("socials") or {} user["role_label"] = hub.get("role_label") or "" user["public"] = bool(hub.get("public")) user["public_url"] = hub.get("public_url") or "" # le hub fait aussi foi pour l'identité (nom, photo) if hub.get("name"): user["name"] = hub["name"] if hub.get("picture"): user["picture"] = hub["picture"] else: user["profile_source"] = "local" return {"user": user, "enabled": bool(KA_SSO_SECRET)} @router.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp