# ============================================================================== # Author: Simon-Pierre Boucher # File: creaka/auth.py # Desc: « Se connecter avec KA ID » — connexion déléguée au hub d'identité du # Groupe KA (groupe-ka.com), calquée sur louka/auth.py / vrai-prix. # Flux : GET /api/auth/ka/login → hub /sso/authorize (state signé) # GET /api/auth/ka/callback → vérifie le JWT HS256 du hub # (stdlib seulement), pose le cookie de session creaka_session # GET /api/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 (optionnel), CREAKA_BASE_URL (prod/ngrok). # ============================================================================== from __future__ import annotations import base64 import hashlib import hmac import json import os import secrets import time from urllib.parse import urlencode import requests from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse router = APIRouter(prefix="/api") CLIENT_ID = "crea-ka" COOKIE = "creaka_session" SESSION_DAYS = 30 KA_HUB_URL = 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: s = os.environ.get("SESSION_SECRET") if not s: raise HTTPException(503, "SESSION_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 — forcée par CREAKA_BASE_URL en prod (ngrok).""" return (os.environ.get("CREAKA_BASE_URL") or 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") != KA_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 = requests.get(f"{KA_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("/auth/config") def auth_config(): return {"ka": bool(_ka_secret())} @router.get("/auth/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"{KA_HUB_URL}/sso/authorize?{urlencode(params)}") @router.get("/auth/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 Créa-Ka. Aucune table users locale : le KA ID du hub EST l'identité (comme Vrai-Prix) — 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("/compte") 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, §hub-profil) 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("/auth/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp