# Author: Simon-Pierre Boucher # Contact: contact@spboucher.ai # Project: Toit-Ka # ----------------------------------------------------------------------------- # auth.py : « Se connecter avec KA ID » — connexion déléguée au hub Groupe KA # (groupe-ka.com), même patron que Lou-Ka / Immo-Ka. # /api/auth/ka/login -> redirige vers {KA_HUB_URL}/sso/authorize (state signé) # /api/auth/ka/callback -> vérifie le JWT HS256 du hub (stdlib), upsert local, # pose le cookie de session # /api/auth/me -> profil (enrichi par le hub, source de vérité) # /api/auth/logout # Session : JWT HS256 maison signé AUTH_SECRET, cookie httponly 30 jours. # Config .env : KA_SSO_SECRET (partagé avec le hub, client « toit-ka »), # KA_HUB_URL, TOITKA_BASE_URL, AUTH_SECRET. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import hashlib import hmac import json import os import time import urllib.parse from fastapi import APIRouter, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db from .hubprofile import fetch_hub_profile, to_epoch router = APIRouter(prefix="/api/auth") CLIENT_ID = "toit-ka" BASE_URL = os.environ.get("TOITKA_BASE_URL", "http://localhost:8097").rstrip("/") KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") KA_SSO_SECRET = os.environ.get("KA_SSO_SECRET", "") SECRET = os.environ.get("AUTH_SECRET", "") or hashlib.sha256( (KA_SSO_SECRET or "toitka-dev").encode()).hexdigest() COOKIE = "toitka_session" SESSION_DAYS = 30 # -- JWT HS256 minimal (aucune dépendance) ------------------------------------- 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 # -- table users (dans toitka.db) ---------------------------------------------- def _ensure_table(con) -> None: con.execute("""CREATE TABLE IF NOT EXISTS users ( sub TEXT PRIMARY KEY, -- KA-ID du hub (ka-…) email TEXT, name TEXT, picture TEXT, created REAL, last_login REAL )""") def _upsert_user(info: dict) -> dict: con = db.connect() try: _ensure_table(con) now = time.time() con.execute( "INSERT INTO users (sub, email, name, picture, created, last_login)" " VALUES (?,?,?,?,?,?)" " ON CONFLICT(sub) DO UPDATE SET email=excluded.email," " name=excluded.name, picture=excluded.picture, last_login=excluded.last_login", (info["sub"], info.get("email", ""), info.get("name", ""), info.get("picture", ""), now, now)) con.commit() finally: con.close() return {"sub": info["sub"], "email": info.get("email", ""), "name": info.get("name", ""), "picture": info.get("picture", "")} def current_user(request: Request) -> dict | None: payload = jwt_decode(request.cookies.get(COOKIE, "")) return payload.get("user") if payload else None # -- vérification du jeton émis par le hub -------------------------------------- def _ka_verify(token: str) -> dict | None: 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 aud = claims.get("aud") if aud != CLIENT_ID and not (isinstance(aud, list) and CLIENT_ID in aud): return None if claims.get("exp", 0) < time.time(): return None return claims except Exception: return None # -- routes ---------------------------------------------------------------------- @router.get("/ka/login") def ka_login(next: str = "/"): """Redirige vers le hub KA ID (groupe-ka.com) — SSO du groupe.""" if not KA_SSO_SECRET: return JSONResponse({"error": "KA_SSO_SECRET manquant (voir .env)"}, status_code=503) state = jwt_encode({"next": next[:200], "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 = 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") ka_id = str(claims.get("ka_id") or f"ka:{claims.get('sub')}") user = _upsert_user({ "sub": ka_id, "email": claims.get("email", ""), "name": claims.get("name", ""), "picture": claims.get("picture") or "", }) user["ka_id"] = ka_id session = jwt_encode({"user": user, "iss": "groupe-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 connecté (ou {user: null}). Le hub Groupe KA est la source de vérité du profil : ses champs remplacent les champs locaux quand il connaît le KA-ID (l'édition se fait sur groupe-ka.com/compte).""" user = current_user(request) if user is None: return {"user": None, "enabled": bool(KA_SSO_SECRET)} out = dict(user) con = db.connect() try: _ensure_table(con) row = con.execute("SELECT created, last_login FROM users WHERE sub=?", (user.get("sub", ""),)).fetchone() finally: con.close() if row is not None: out["created_at"] = row[0] out["last_login"] = row[1] ka_id = user.get("ka_id") or "" out["ka_id"] = ka_id out["provider"] = "ka-id" out["profile_source"] = "local" hub = fetch_hub_profile(ka_id) if ka_id else None if hub is not None: hub_name = (hub.get("name") or "").strip() out.update({ "name": hub_name or out.get("name", ""), "bio": hub.get("bio") or "", "city": hub.get("city") or "", "phone": hub.get("phone") or "", "website": hub.get("website") or "", "socials": hub.get("socials") or {}, "public": bool(hub.get("public")), "role_label": hub.get("role_label") or "", "job_title": hub.get("job_title") or "", "company": hub.get("company") or "", "age": hub.get("age"), "public_url": hub.get("public_url") or "", "created_at": to_epoch(hub.get("created_at")) or out.get("created_at"), "profile_source": "groupe-ka", }) if hub.get("picture"): out["picture"] = hub["picture"] return {"user": out, "enabled": bool(KA_SSO_SECRET)} @router.post("/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp