# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/auth.py # Rôle : Connexion KA ID — SSO du hub Groupe KA (groupe-ka.com) # Flux (hérité de Lou·Ka, client_id « job-ka ») : # GET /api/auth/ka/login -> redirige vers {hub}/sso/authorize (state signé) # GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub, upsert # l'utilisateur local, pose le cookie de session # GET /api/auth/config -> {"ka": bool} (bouton affiché ou non) # GET /api/me -> profil de la session courante (ou 401) # POST /api/auth/logout -> efface le cookie # Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie # httpOnly propre à Job·Ka (« jobka_session »). La création de compte se # fait sur le hub : https://www.groupe-ka.com/connexion. # Config .env : KA_SSO_SECRET (partagé avec le hub), KA_HUB_URL (optionnel), # JOBKA_BASE_URL (optionnel — défaut https://www.job-ka.com). # Créé : 2026-08-17 Modifié : 2026-08-17 # ============================================================================= from __future__ import annotations import base64 import hashlib import hmac import json import os import secrets import time from urllib.parse import urlencode from fastapi import APIRouter, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from . import db router = APIRouter(prefix="/api") COOKIE = "jobka_session" SESSION_DAYS = 30 CLIENT_ID = "job-ka" KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") def _ka_secret() -> bytes | None: s = os.environ.get("KA_SSO_SECRET") return s.encode() if s else None def _session_secret() -> bytes: """Clé de signature des sessions locales — dérivée du secret SSO partagé (aucune variable .env supplémentaire requise).""" s = _ka_secret() if not s: raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") return hashlib.sha256(b"jobka-session|" + s).digest() def _base_url(request: Request) -> str: """URL publique de l'app. Le trafic prod passe par ngrok (schéma http vu du serveur) : on force https sur le domaine officiel. JOBKA_BASE_URL (optionnel) a préséance — utile en dev local.""" env = os.environ.get("JOBKA_BASE_URL") if env: return env.rstrip("/") host = request.url.hostname or "" if host.endswith("job-ka.com"): return "https://www.job-ka.com" return str(request.base_url).rstrip("/") # -- jeton signé local (payload JSON -> base64url.signature) ------------------- def _sign(payload: dict) -> str: raw = base64.urlsafe_b64encode( json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=") sig = hmac.new(_session_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(_session_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 ({uid, 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 KA ------------------------------- 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 # -- routes --------------------------------------------------------------------- @router.get("/auth/config") def auth_config(): return {"ka": bool(_ka_secret())} @router.get("/auth/ka/login") def ka_login(request: Request, next: str = ""): """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com). `next` (chemin local seulement) = page de retour après connexion — transporté dans le state signé, donc infalsifiable.""" if not _ka_secret(): raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)") # anti open-redirect : uniquement un chemin local ("/…", pas "//…") nxt = next if next.startswith("/") and not next.startswith("//") else "" state = _sign({"n": secrets.token_urlsafe(12), "next": nxt, "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, upsert l'utilisateur local, pose le cookie de session Job·Ka.""" st = _verify(state) if ka_token else None if st 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é") email = (claims.get("email") or "").lower() name = claims.get("name") or (email.split("@")[0] if email else "membre") picture = claims.get("picture") or "" ka_sub = f"ka:{claims.get('sub')}" # clé stable côté hub ka_id = (claims.get("ka_id") or "").strip() # créé À LA SOURCE par le hub con = db.connect() now = time.time() con.execute( """INSERT INTO users (ka_sub, email, name, picture, created_at, last_login) VALUES (?,?,?,?,?,?) ON CONFLICT(ka_sub) DO UPDATE SET email=excluded.email, name=excluded.name, picture=excluded.picture, last_login=excluded.last_login""", (ka_sub, email, name, picture, now, now)) row = con.execute("SELECT id FROM users WHERE ka_sub=?", (ka_sub,)).fetchone() uid = row["id"] if ka_id: import sqlite3 try: con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid)) except sqlite3.IntegrityError: pass # KA ID déjà porté par une autre ligne locale : on garde con.commit() con.close() session = _sign({ "uid": uid, "ka_id": ka_id, "email": email, "name": name, "picture": picture, "exp": time.time() + SESSION_DAYS * 86400, }) nxt = st.get("next") or "" resp = RedirectResponse(nxt if nxt.startswith("/") and not nxt.startswith("//") else "/?login=ok") 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é") con = db.connect() row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone() con.close() if row is None: raise HTTPException(401, "compte introuvable") return { "uid": row["id"], "ka_id": row["ka_id"], "email": row["email"], "name": row["name"], "picture": row["picture"], "created_at": row["created_at"], "last_login": row["last_login"], "provider": "ka-id", } @router.post("/auth/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE, path="/") return resp