API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com
Python 60.9%
HTML 21%
TypeScript 7.3%
JavaScript 5.2%
CSS 4.8%
Shell 0.8%
1# ============================================2# Projet : API-KA3# Fichier : src/api/routes/auth.py4# Node : m3u96b5# Author : Simon-Pierre Boucher6# Contact : contact@spboucher.ai7# Date : 2026-08-178# ============================================9"""« Se connecter avec KA ID » — connexion déléguée au hub d'identité du10Groupe KA (groupe-ka.com), calquée sur creaka/auth.py (stdlib pur pour la11crypto, httpx pour le profil).1213Flux : GET /api/auth/ka/login → hub {KA_HUB_URL}/sso/authorize (state signé)14 GET /api/auth/ka/callback → vérifie le JWT HS256 ``ka_token`` du hub15 (aud="api-ka"), pose le cookie de session signé ``apika_session``16 GET /api/auth/me · POST /api/auth/logout · GET /api/auth/config1718Session SANS stockage local (aucune table users) : le hub est LA source de19vérité du profil (fetch signé HMAC, cache 60 s).20.env : KA_SSO_SECRET (partagé avec le hub), SESSION_SECRET,21 KA_HUB_URL, APIKA_BASE_URL (optionnel — sinon dérivé de NGROK_DOMAIN).22"""2324from __future__ import annotations2526import base6427import hashlib28import hmac29import json30import os31import secrets32import time33from urllib.parse import urlencode3435import httpx36from fastapi import APIRouter, HTTPException, Request37from fastapi.responses import JSONResponse, RedirectResponse3839router = APIRouter(prefix="/api/auth", tags=["auth"])4041CLIENT_ID = "api-ka"42COOKIE = "apika_session"43SESSION_DAYS = 30444546def _hub_url() -> str:47 return os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")484950_profile_cache: dict[str, tuple[float, dict | None]] = {}51_PROFILE_TTL = 60.0525354def _secret() -> bytes:55 """Secret de session local — SESSION_SECRET, sinon dérivé de KA_SSO_SECRET."""56 s = os.environ.get("SESSION_SECRET") or os.environ.get("KA_SSO_SECRET")57 if not s:58 raise HTTPException(503, "SESSION_SECRET / KA_SSO_SECRET manquant (voir .env)")59 return s.encode()606162def _ka_secret() -> bytes | None:63 s = os.environ.get("KA_SSO_SECRET")64 return s.encode() if s else None656667def _base_url(request: Request) -> str:68 """URL publique de l'app — APIKA_BASE_URL, sinon https://{NGROK_DOMAIN}."""69 explicit = os.environ.get("APIKA_BASE_URL")70 if explicit:71 return explicit.rstrip("/")72 domain = os.environ.get("NGROK_DOMAIN")73 if domain:74 return f"https://{domain}"75 return str(request.base_url).rstrip("/")767778# -- jeton signé maison (payload JSON -> base64url.signature) -------------------798081def _sign(payload: dict) -> str:82 raw = (83 base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())84 .decode()85 .rstrip("=")86 )87 sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()88 return f"{raw}.{sig}"899091def _verify(token: str) -> dict | None:92 try:93 raw, sig = token.rsplit(".", 1)94 expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()95 if not hmac.compare_digest(sig, expected):96 return None97 payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))98 if payload.get("exp", 0) < time.time():99 return None100 return payload101 except Exception:102 return None103104105def current_user(request: Request) -> dict | None:106 """Payload de session ({ka_id, email, name, picture…}) ou None."""107 token = request.cookies.get(COOKIE)108 return _verify(token) if token else None109110111# -- vérification du JWT HS256 émis par le hub (stdlib seulement) ---------------112113114def _b64url(s: str) -> bytes:115 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))116117118def _verify_ka_token(token: str) -> dict | None:119 secret = _ka_secret()120 if not secret:121 return None122 try:123 h_b64, p_b64, s_b64 = token.split(".")124 expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest()125 if not hmac.compare_digest(_b64url(s_b64), expected):126 return None127 if json.loads(_b64url(h_b64)).get("alg") != "HS256":128 return None129 claims = json.loads(_b64url(p_b64))130 if claims.get("iss") != _hub_url():131 return None132 aud = claims.get("aud")133 if (aud if isinstance(aud, str) else "") != CLIENT_ID and CLIENT_ID not in (134 aud if isinstance(aud, list) else []135 ):136 return None137 if claims.get("exp", 0) < time.time():138 return None139 return claims140 except Exception:141 return None142143144# -- profil dicté par le hub (source de vérité, cache 60 s) ---------------------145146147def fetch_hub_profile(ka_id: str) -> dict | None:148 """GET signé {hub}/api/sso/profile — sig = HMAC(secret, client.ka_id.ts)."""149 secret = _ka_secret()150 if not secret or not ka_id:151 return None152 now = time.time()153 hit = _profile_cache.get(ka_id)154 if hit and now - hit[0] < _PROFILE_TTL:155 return hit[1]156 ts = str(int(now))157 sig = hmac.new(secret, f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest()158 profile = None159 try:160 resp = httpx.get(161 f"{_hub_url()}/api/sso/profile",162 params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig},163 timeout=8,164 )165 if resp.status_code == 200:166 profile = resp.json()167 except Exception:168 pass169 _profile_cache[ka_id] = (now, profile) # cache positif ET négatif170 return profile171172173# -- routes ---------------------------------------------------------------------174175176@router.get("/config")177def auth_config():178 return {"ka": bool(_ka_secret())}179180181@router.get("/ka/login")182def ka_login(request: Request):183 """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""184 if not _ka_secret():185 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")186 state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})187 params = {188 "client_id": CLIENT_ID,189 "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",190 "state": state,191 }192 return RedirectResponse(f"{_hub_url()}/sso/authorize?{urlencode(params)}")193194195@router.get("/ka/callback")196def ka_callback(request: Request, ka_token: str = "", state: str = ""):197 """Retour SSO : vérifie le jeton du hub, pose le cookie de session API-KA.198199 Aucune table users locale : le KA ID du hub EST l'identité — la session200 transporte les claims utiles, le profil riche est relu au hub à la demande.201 """202 if not ka_token or _verify(state) is None:203 raise HTTPException(400, "state invalide ou expiré")204 claims = _verify_ka_token(ka_token)205 if claims is None:206 raise HTTPException(401, "jeton KA invalide ou expiré")207 session = _sign(208 {209 "ka_id": (claims.get("ka_id") or "").strip(),210 "sub": str(claims.get("sub") or ""),211 "email": (claims.get("email") or "").lower(),212 "name": claims.get("name") or "membre",213 "picture": claims.get("picture") or "",214 "role": claims.get("ka_role") or "",215 "exp": time.time() + SESSION_DAYS * 86400,216 }217 )218 resp = RedirectResponse("/")219 resp.set_cookie(220 COOKIE,221 session,222 max_age=SESSION_DAYS * 86400,223 httponly=True,224 secure=_base_url(request).startswith("https"),225 samesite="lax",226 path="/",227 )228 return resp229230231@router.get("/me")232def me(request: Request):233 user = current_user(request)234 if user is None:235 raise HTTPException(401, "non connecté")236 out = {237 "provider": "ka-id",238 **{k: user.get(k) for k in ("ka_id", "email", "name", "picture", "role")},239 }240 profile = fetch_hub_profile(user.get("ka_id") or "")241 if profile: # le hub dicte le profil (nom/photo/rôle à jour)242 out.update(243 {244 k: profile.get(k)245 for k in (246 "name",247 "picture",248 "role",249 "role_label",250 "bio",251 "city",252 "job_title",253 "company",254 "website",255 "public_url",256 )257 if profile.get(k) not in (None, "")258 }259 )260 return out261262263@router.post("/logout")264def logout():265 resp = JSONResponse({"ok": True})266 resp.delete_cookie(COOKIE, path="/")267 return resp268