Créa·Ka — annuaire public cross-plateforme des créateurs de contenu québécois (crea-ka.com)
Python 73.6%
HTML 13.2%
TypeScript 6%
JavaScript 4.5%
CSS 1.7%
Dockerfile 0.6%
1# ==============================================================================2# Author: Simon-Pierre Boucher <contact@spboucher.ai>3# File: creaka/auth.py4# Desc: « Se connecter avec KA ID » — connexion déléguée au hub d'identité du5# Groupe KA (groupe-ka.com), calquée sur louka/auth.py / vrai-prix.6# Flux : GET /api/auth/ka/login → hub /sso/authorize (state signé)7# GET /api/auth/ka/callback → vérifie le JWT HS256 du hub8# (stdlib seulement), pose le cookie de session creaka_session9# GET /api/me · POST /api/auth/logout · GET /api/auth/config10# Session SANS stockage local (aucune table users) ; le hub est LA11# source de vérité du profil (fetch signé HMAC, cache 60 s).12# .env : KA_SSO_SECRET (partagé avec le hub), SESSION_SECRET,13# KA_HUB_URL (optionnel), CREAKA_BASE_URL (prod/ngrok).14# ==============================================================================15from __future__ import annotations1617import base6418import hashlib19import hmac20import json21import os22import secrets23import time24from urllib.parse import urlencode2526import requests27from fastapi import APIRouter, HTTPException, Request28from fastapi.responses import JSONResponse, RedirectResponse2930router = APIRouter(prefix="/api")3132CLIENT_ID = "crea-ka"33COOKIE = "creaka_session"34SESSION_DAYS = 3035KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")3637_profile_cache: dict[str, tuple[float, dict | None]] = {}38_PROFILE_TTL = 60.0394041def _secret() -> bytes:42 s = os.environ.get("SESSION_SECRET")43 if not s:44 raise HTTPException(503, "SESSION_SECRET manquant (voir .env)")45 return s.encode()464748def _ka_secret() -> bytes | None:49 s = os.environ.get("KA_SSO_SECRET")50 return s.encode() if s else None515253def _base_url(request: Request) -> str:54 """URL publique de l'app — forcée par CREAKA_BASE_URL en prod (ngrok)."""55 return (os.environ.get("CREAKA_BASE_URL")56 or str(request.base_url)).rstrip("/")575859# -- jeton signé maison (payload JSON -> base64url.signature) -------------------6061def _sign(payload: dict) -> str:62 raw = base64.urlsafe_b64encode(63 json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")64 sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()65 return f"{raw}.{sig}"666768def _verify(token: str) -> dict | None:69 try:70 raw, sig = token.rsplit(".", 1)71 expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()72 if not hmac.compare_digest(sig, expected):73 return None74 payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))75 if payload.get("exp", 0) < time.time():76 return None77 return payload78 except Exception:79 return None808182def current_user(request: Request) -> dict | None:83 """Payload de session ({ka_id, email, name, picture…}) ou None."""84 token = request.cookies.get(COOKIE)85 return _verify(token) if token else None868788# -- vérification du JWT HS256 émis par le hub (stdlib seulement) ---------------8990def _b64url(s: str) -> bytes:91 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))929394def _verify_ka_token(token: str) -> dict | None:95 secret = _ka_secret()96 if not secret:97 return None98 try:99 h_b64, p_b64, s_b64 = token.split(".")100 expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),101 hashlib.sha256).digest()102 if not hmac.compare_digest(_b64url(s_b64), expected):103 return None104 if json.loads(_b64url(h_b64)).get("alg") != "HS256":105 return None106 claims = json.loads(_b64url(p_b64))107 if claims.get("iss") != KA_HUB_URL:108 return None109 aud = claims.get("aud")110 if (aud if isinstance(aud, str) else "") != CLIENT_ID \111 and CLIENT_ID not in (aud if isinstance(aud, list) else []):112 return None113 if claims.get("exp", 0) < time.time():114 return None115 return claims116 except Exception:117 return None118119120# -- profil dicté par le hub (source de vérité, cache 60 s) ---------------------121122def fetch_hub_profile(ka_id: str) -> dict | None:123 """GET signé {hub}/api/sso/profile — sig = HMAC(secret, client.ka_id.ts)."""124 secret = _ka_secret()125 if not secret or not ka_id:126 return None127 now = time.time()128 hit = _profile_cache.get(ka_id)129 if hit and now - hit[0] < _PROFILE_TTL:130 return hit[1]131 ts = str(int(now))132 sig = hmac.new(secret, f"{CLIENT_ID}.{ka_id}.{ts}".encode(),133 hashlib.sha256).hexdigest()134 profile = None135 try:136 resp = requests.get(f"{KA_HUB_URL}/api/sso/profile",137 params={"client_id": CLIENT_ID, "ka_id": ka_id,138 "ts": ts, "sig": sig}, timeout=8)139 if resp.status_code == 200:140 profile = resp.json()141 except Exception:142 pass143 _profile_cache[ka_id] = (now, profile) # cache positif ET négatif144 return profile145146147# -- routes ---------------------------------------------------------------------148149@router.get("/auth/config")150def auth_config():151 return {"ka": bool(_ka_secret())}152153154@router.get("/auth/ka/login")155def ka_login(request: Request):156 """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""157 if not _ka_secret():158 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")159 state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})160 params = {161 "client_id": CLIENT_ID,162 "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",163 "state": state,164 }165 return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")166167168@router.get("/auth/ka/callback")169def ka_callback(request: Request, ka_token: str = "", state: str = ""):170 """Retour SSO : vérifie le jeton du hub, pose le cookie de session Créa-Ka.171172 Aucune table users locale : le KA ID du hub EST l'identité (comme173 Vrai-Prix) — la session transporte les claims utiles, le profil riche174 est relu au hub à la demande.175 """176 if not ka_token or _verify(state) is None:177 raise HTTPException(400, "state invalide ou expiré")178 claims = _verify_ka_token(ka_token)179 if claims is None:180 raise HTTPException(401, "jeton KA invalide ou expiré")181 session = _sign({182 "ka_id": (claims.get("ka_id") or "").strip(),183 "sub": str(claims.get("sub") or ""),184 "email": (claims.get("email") or "").lower(),185 "name": claims.get("name") or "membre",186 "picture": claims.get("picture") or "",187 "role": claims.get("ka_role") or "",188 "exp": time.time() + SESSION_DAYS * 86400,189 })190 resp = RedirectResponse("/compte")191 resp.set_cookie(192 COOKIE, session,193 max_age=SESSION_DAYS * 86400,194 httponly=True,195 secure=_base_url(request).startswith("https"),196 samesite="lax",197 path="/",198 )199 return resp200201202@router.get("/me")203def me(request: Request):204 user = current_user(request)205 if user is None:206 raise HTTPException(401, "non connecté")207 out = {"provider": "ka-id", **{k: user.get(k) for k in208 ("ka_id", "email", "name", "picture", "role")}}209 profile = fetch_hub_profile(user.get("ka_id") or "")210 if profile: # le hub dicte le profil (nom/photo/rôle à jour, §hub-profil)211 out.update({k: profile.get(k) for k in212 ("name", "picture", "role", "role_label", "bio", "city",213 "job_title", "company", "website", "public_url")214 if profile.get(k) not in (None, "")})215 return out216217218@router.post("/auth/logout")219def logout():220 resp = JSONResponse({"ok": True})221 resp.delete_cookie(COOKIE, path="/")222 return resp223