# ----------------------------------------------------------------------------- # Ora-Ka — Plateforme unifiée des agrégateurs Ka # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # auth.py : « Se connecter avec KA ID » — SSO du Groupe KA (hub groupe-ka.com) # # Contrat SSO : # 1. /api/auth/ka/login → redirige vers {KA_HUB_URL}/sso/authorize # (client_id=ora-ka, redirect_uri={ORAKA_BASE_URL}/api/auth/ka/callback, # state = jeton opaque signé localement avec SESSION_SECRET) # 2. /api/auth/ka/callback?ka_token=&state=… → vérifie le JWT HS256 # signé KA_SSO_SECRET (alg + iss + aud + exp), upsert l'utilisateur # (clé = ka_id, LE MÊME sur tout le groupe), pose la session. # 3. Session locale : cookie httpOnly `oraka_session` signé HMAC avec # SESSION_SECRET, 30 jours, path=/ (visible aussi sous /immo, /lou…). # # Vérification JWT en stdlib pure (hmac/hashlib/base64/json) — zéro dépendance. # Utilisateurs : SQLite dédié au niveau racine, data/oraka-users.db. # Config .env : KA_SSO_SECRET, KA_HUB_URL, ORAKA_BASE_URL, SESSION_SECRET. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import hashlib import hmac import json import os import sqlite3 import time import urllib.parse from pathlib import Path from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, RedirectResponse router = APIRouter(prefix="/api/auth") ROOT = Path(__file__).resolve().parent.parent DB_PATH = ROOT / "data" / "oraka-users.db" KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") BASE_URL = os.environ.get("ORAKA_BASE_URL", "http://localhost:8200").rstrip("/") SECRET = os.environ.get("SESSION_SECRET", "") or hashlib.sha256( (KA_SSO_SECRET or "oraka-dev").encode()).hexdigest() COOKIE = "oraka_session" SESSION_DAYS = 30 CLIENT_ID = "ora-ka" # -- JWT HS256 minimal (stdlib seulement) ------------------------------------- def _b64(d: bytes) -> str: return base64.urlsafe_b64encode(d).rstrip(b"=").decode() def _unb64(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def jwt_encode(payload: dict) -> str: head = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) body = _b64(json.dumps(payload, separators=(",", ":")).encode()) sig = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) return f"{head}.{body}.{sig}" def jwt_decode(token: str) -> dict | None: try: head, body, sig = token.split(".") good = _b64(hmac.new(SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None payload = json.loads(_unb64(body)) if payload.get("exp", 0) < time.time(): return None return payload except Exception: return None def _ka_verify(token: str) -> dict | None: """Vérifie un JWT HS256 émis par le hub KA ID (signature+alg+iss+aud+exp).""" if not KA_SSO_SECRET: return None try: head, body, sig = token.split(".") good = _b64(hmac.new(KA_SSO_SECRET.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) if not hmac.compare_digest(sig, good): return None if json.loads(_unb64(head)).get("alg") != "HS256": return None claims = json.loads(_unb64(body)) if claims.get("iss") != KA_HUB_URL: return None if claims.get("aud") != CLIENT_ID: return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- table users (SQLite dédié au niveau racine) -------------------------------- def _connect() -> sqlite3.Connection: DB_PATH.parent.mkdir(parents=True, exist_ok=True) con = sqlite3.connect(DB_PATH, timeout=10) con.execute("""CREATE TABLE IF NOT EXISTS users ( ka_id TEXT PRIMARY KEY, -- KA-ID du groupe (« ka-0123456789 », identique partout) email TEXT, name TEXT, picture TEXT, role TEXT, created REAL, last_login REAL )""") return con def _upsert_user(claims: dict) -> dict: ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") user = { "ka_id": ka_id, "email": claims.get("email", "") or "", "name": claims.get("name", "") or "", "picture": claims.get("picture", "") or "", "role": claims.get("ka_role", "") or "", } con = _connect() try: now = time.time() con.execute( "INSERT INTO users (ka_id, email, name, picture, role, created, last_login)" " VALUES (?,?,?,?,?,?,?)" " ON CONFLICT(ka_id) DO UPDATE SET email=excluded.email, name=excluded.name," " picture=excluded.picture, role=excluded.role, last_login=excluded.last_login", (user["ka_id"], user["email"], user["name"], user["picture"], user["role"], now, now)) con.commit() finally: con.close() return user def current_user(request: Request) -> dict | None: payload = jwt_decode(request.cookies.get(COOKIE, "")) return payload.get("user") if payload else None def _safe_next(next: str) -> str: """Restreint `next` aux chemins internes (pas d'open redirect).""" if not next.startswith("/") or next.startswith("//") or "\\" in next: return "/" return next[:200] # -- routes ---------------------------------------------------------------------- @router.get("/ka/login") def ka_login(next: str = "/"): """Redirige vers le hub KA ID (groupe-ka.com) — SSO du Groupe KA.""" if not KA_SSO_SECRET: return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, status_code=503) state = jwt_encode({"next": _safe_next(next), "exp": time.time() + 600}) params = { "client_id": CLIENT_ID, "redirect_uri": f"{BASE_URL}/api/auth/ka/callback", "state": state, } return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urllib.parse.urlencode(params)}") @router.get("/ka/callback") def ka_callback(ka_token: str = "", state: str = ""): """Retour du hub : vérifie le jeton, upsert l'utilisateur, pose la session.""" st = jwt_decode(state) or {} dest = _safe_next(st.get("next") or "/") claims = _ka_verify(ka_token) if ka_token else None if not st or claims is None: return RedirectResponse("/?auth=echec") user = _upsert_user(claims) session = jwt_encode({"user": user, "iss": "ora-ka", "exp": time.time() + SESSION_DAYS * 86400}) resp = RedirectResponse(dest) resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400, httponly=True, samesite="lax", secure=BASE_URL.startswith("https"), path="/") return resp @router.get("/me") def me(request: Request): """Profil de l'utilisateur connecté (ou {user: null}). `enabled` indique si le SSO KA ID est configuré (le frontend masque le bouton sinon).""" return {"user": current_user(request), "enabled": bool(KA_SSO_SECRET)} @router.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp