# ============================================ # Projet : API-KA # Fichier : src/api/routes/auth.py # Node : m3u96b # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Date : 2026-08-17 # ============================================ """« Se connecter avec KA ID » — connexion déléguée au hub d'identité du Groupe KA (groupe-ka.com), calquée sur creaka/auth.py (stdlib pur pour la crypto, httpx pour le profil). Flux : GET /api/auth/ka/login → hub {KA_HUB_URL}/sso/authorize (state signé) GET /api/auth/ka/callback → vérifie le JWT HS256 ``ka_token`` du hub (aud="api-ka"), pose le cookie de session signé ``apika_session`` GET /api/auth/me · POST /api/auth/logout · GET /api/auth/config Session SANS stockage local (aucune table users) : le hub est LA source de vérité du profil (fetch signé HMAC, cache 60 s). .env : KA_SSO_SECRET (partagé avec le hub), SESSION_SECRET, KA_HUB_URL, APIKA_BASE_URL (optionnel — sinon dérivé de NGROK_DOMAIN). """ from __future__ import annotations import base64 import hashlib import hmac import json import os import secrets import time from urllib.parse import urlencode import httpx from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse router = APIRouter(prefix="/api/auth", tags=["auth"]) CLIENT_ID = "api-ka" COOKIE = "apika_session" SESSION_DAYS = 30 def _hub_url() -> str: return os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") _profile_cache: dict[str, tuple[float, dict | None]] = {} _PROFILE_TTL = 60.0 def _secret() -> bytes: """Secret de session local — SESSION_SECRET, sinon dérivé de KA_SSO_SECRET.""" s = os.environ.get("SESSION_SECRET") or os.environ.get("KA_SSO_SECRET") if not s: raise HTTPException(503, "SESSION_SECRET / KA_SSO_SECRET manquant (voir .env)") return s.encode() def _ka_secret() -> bytes | None: s = os.environ.get("KA_SSO_SECRET") return s.encode() if s else None def _base_url(request: Request) -> str: """URL publique de l'app — APIKA_BASE_URL, sinon https://{NGROK_DOMAIN}.""" explicit = os.environ.get("APIKA_BASE_URL") if explicit: return explicit.rstrip("/") domain = os.environ.get("NGROK_DOMAIN") if domain: return f"https://{domain}" return str(request.base_url).rstrip("/") # -- jeton signé maison (payload JSON -> base64url.signature) ------------------- def _sign(payload: dict) -> str: raw = ( base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()) .decode() .rstrip("=") ) sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() return f"{raw}.{sig}" def _verify(token: str) -> dict | None: try: raw, sig = token.rsplit(".", 1) expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): return None payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None def current_user(request: Request) -> dict | None: """Payload de session ({ka_id, email, name, picture…}) ou None.""" token = request.cookies.get(COOKIE) return _verify(token) if token else None # -- vérification du JWT HS256 émis par le hub (stdlib seulement) --------------- def _b64url(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _verify_ka_token(token: str) -> dict | None: secret = _ka_secret() if not secret: return None try: h_b64, p_b64, s_b64 = token.split(".") expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(), hashlib.sha256).digest() if not hmac.compare_digest(_b64url(s_b64), expected): return None if json.loads(_b64url(h_b64)).get("alg") != "HS256": return None claims = json.loads(_b64url(p_b64)) if claims.get("iss") != _hub_url(): return None aud = claims.get("aud") if (aud if isinstance(aud, str) else "") != CLIENT_ID and CLIENT_ID not in ( aud if isinstance(aud, list) else [] ): return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- profil dicté par le hub (source de vérité, cache 60 s) --------------------- def fetch_hub_profile(ka_id: str) -> dict | None: """GET signé {hub}/api/sso/profile — sig = HMAC(secret, client.ka_id.ts).""" secret = _ka_secret() if not secret or not ka_id: return None now = time.time() hit = _profile_cache.get(ka_id) if hit and now - hit[0] < _PROFILE_TTL: return hit[1] ts = str(int(now)) sig = hmac.new(secret, f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest() profile = None try: resp = httpx.get( f"{_hub_url()}/api/sso/profile", params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig}, timeout=8, ) if resp.status_code == 200: profile = resp.json() except Exception: pass _profile_cache[ka_id] = (now, profile) # cache positif ET négatif return profile # -- routes --------------------------------------------------------------------- @router.get("/config") def auth_config(): return {"ka": bool(_ka_secret())} @router.get("/ka/login") def ka_login(request: Request): """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).""" if not _ka_secret(): raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600}) params = { "client_id": CLIENT_ID, "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{_hub_url()}/sso/authorize?{urlencode(params)}") @router.get("/ka/callback") def ka_callback(request: Request, ka_token: str = "", state: str = ""): """Retour SSO : vérifie le jeton du hub, pose le cookie de session API-KA. Aucune table users locale : le KA ID du hub EST l'identité — la session transporte les claims utiles, le profil riche est relu au hub à la demande. """ if not ka_token or _verify(state) is None: raise HTTPException(400, "state invalide ou expiré") claims = _verify_ka_token(ka_token) if claims is None: raise HTTPException(401, "jeton KA invalide ou expiré") session = _sign( { "ka_id": (claims.get("ka_id") or "").strip(), "sub": str(claims.get("sub") or ""), "email": (claims.get("email") or "").lower(), "name": claims.get("name") or "membre", "picture": claims.get("picture") or "", "role": claims.get("ka_role") or "", "exp": time.time() + SESSION_DAYS * 86400, } ) resp = RedirectResponse("/") resp.set_cookie( COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, secure=_base_url(request).startswith("https"), samesite="lax", path="/", ) return resp @router.get("/me") def me(request: Request): user = current_user(request) if user is None: raise HTTPException(401, "non connecté") out = { "provider": "ka-id", **{k: user.get(k) for k in ("ka_id", "email", "name", "picture", "role")}, } profile = fetch_hub_profile(user.get("ka_id") or "") if profile: # le hub dicte le profil (nom/photo/rôle à jour) out.update( { k: profile.get(k) for k in ( "name", "picture", "role", "role_label", "bio", "city", "job_title", "company", "website", "public_url", ) if profile.get(k) not in (None, "") } ) return out @router.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp