SPB Git forge

spb/api-ka

Public

API-KA — plateforme centrale : collecte quotidienne des 8 services KA, historisation append-only et API publique sur www.api-ka.com

48commits 1branches 0releases
5.9 MBsize
maindefault branch
19 days agolast push
Python 60.9% HTML 21% TypeScript 7.3% JavaScript 5.2% CSS 4.8% Shell 0.8%
8.3 KB · 268 lines python
Raw Blame History
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