HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/auth.py6# Rôle : Connexion KA ID — SSO du hub Groupe KA (groupe-ka.com)7# Flux (hérité de Lou·Ka, client_id « job-ka ») :8# GET /api/auth/ka/login -> redirige vers {hub}/sso/authorize (state signé)9# GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub, upsert10# l'utilisateur local, pose le cookie de session11# GET /api/auth/config -> {"ka": bool} (bouton affiché ou non)12# GET /api/me -> profil de la session courante (ou 401)13# POST /api/auth/logout -> efface le cookie14# Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie15# httpOnly propre à Job·Ka (« jobka_session »). La création de compte se16# fait sur le hub : https://www.groupe-ka.com/connexion.17# Config .env : KA_SSO_SECRET (partagé avec le hub), KA_HUB_URL (optionnel),18# JOBKA_BASE_URL (optionnel — défaut https://www.job-ka.com).19# Créé : 2026-08-17 Modifié : 2026-08-1720# =============================================================================21from __future__ import annotations2223import base6424import hashlib25import hmac26import json27import os28import secrets29import time30from urllib.parse import urlencode3132from fastapi import APIRouter, HTTPException, Request33from fastapi.responses import JSONResponse, RedirectResponse3435from . import db3637router = APIRouter(prefix="/api")3839COOKIE = "jobka_session"40SESSION_DAYS = 3041CLIENT_ID = "job-ka"4243KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")444546def _ka_secret() -> bytes | None:47 s = os.environ.get("KA_SSO_SECRET")48 return s.encode() if s else None495051def _session_secret() -> bytes:52 """Clé de signature des sessions locales — dérivée du secret SSO partagé53 (aucune variable .env supplémentaire requise)."""54 s = _ka_secret()55 if not s:56 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")57 return hashlib.sha256(b"jobka-session|" + s).digest()585960def _base_url(request: Request) -> str:61 """URL publique de l'app. Le trafic prod passe par ngrok (schéma http vu62 du serveur) : on force https sur le domaine officiel. JOBKA_BASE_URL63 (optionnel) a préséance — utile en dev local."""64 env = os.environ.get("JOBKA_BASE_URL")65 if env:66 return env.rstrip("/")67 host = request.url.hostname or ""68 if host.endswith("job-ka.com"):69 return "https://www.job-ka.com"70 return str(request.base_url).rstrip("/")717273# -- jeton signé local (payload JSON -> base64url.signature) -------------------7475def _sign(payload: dict) -> str:76 raw = base64.urlsafe_b64encode(77 json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")78 sig = hmac.new(_session_secret(), raw.encode(), hashlib.sha256).hexdigest()79 return f"{raw}.{sig}"808182def _verify(token: str) -> dict | None:83 try:84 raw, sig = token.rsplit(".", 1)85 expected = hmac.new(_session_secret(), raw.encode(),86 hashlib.sha256).hexdigest()87 if not hmac.compare_digest(sig, expected):88 return None89 payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))90 if payload.get("exp", 0) < time.time():91 return None92 return payload93 except Exception:94 return None959697def current_user(request: Request) -> dict | None:98 """Payload de session ({uid, ka_id, email, name, picture}) ou None."""99 token = request.cookies.get(COOKIE)100 return _verify(token) if token else None101102103# -- vérification du JWT HS256 émis par le hub KA -------------------------------104105def _b64url(s: str) -> bytes:106 return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))107108109def _verify_ka_token(token: str) -> dict | None:110 secret = _ka_secret()111 if not secret:112 return None113 try:114 h_b64, p_b64, s_b64 = token.split(".")115 expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),116 hashlib.sha256).digest()117 if not hmac.compare_digest(_b64url(s_b64), expected):118 return None119 if json.loads(_b64url(h_b64)).get("alg") != "HS256":120 return None121 claims = json.loads(_b64url(p_b64))122 if claims.get("iss") != KA_HUB_URL:123 return None124 aud = claims.get("aud")125 if (aud if isinstance(aud, str) else "") != CLIENT_ID \126 and CLIENT_ID not in (aud if isinstance(aud, list) else []):127 return None128 if claims.get("exp", 0) < time.time():129 return None130 return claims131 except Exception:132 return None133134135# -- routes ---------------------------------------------------------------------136137@router.get("/auth/config")138def auth_config():139 return {"ka": bool(_ka_secret())}140141142@router.get("/auth/ka/login")143def ka_login(request: Request, next: str = ""):144 """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com).145 `next` (chemin local seulement) = page de retour après connexion —146 transporté dans le state signé, donc infalsifiable."""147 if not _ka_secret():148 raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")149 # anti open-redirect : uniquement un chemin local ("/…", pas "//…")150 nxt = next if next.startswith("/") and not next.startswith("//") else ""151 state = _sign({"n": secrets.token_urlsafe(12), "next": nxt,152 "exp": time.time() + 600})153 params = {154 "client_id": CLIENT_ID,155 "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",156 "state": state,157 }158 return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")159160161@router.get("/auth/ka/callback")162def ka_callback(request: Request, ka_token: str = "", state: str = ""):163 """Retour SSO : vérifie le jeton du hub, upsert l'utilisateur local,164 pose le cookie de session Job·Ka."""165 st = _verify(state) if ka_token else None166 if st is None:167 raise HTTPException(400, "state invalide ou expiré")168 claims = _verify_ka_token(ka_token)169 if claims is None:170 raise HTTPException(401, "jeton KA invalide ou expiré")171172 email = (claims.get("email") or "").lower()173 name = claims.get("name") or (email.split("@")[0] if email else "membre")174 picture = claims.get("picture") or ""175 ka_sub = f"ka:{claims.get('sub')}" # clé stable côté hub176 ka_id = (claims.get("ka_id") or "").strip() # créé À LA SOURCE par le hub177178 con = db.connect()179 now = time.time()180 con.execute(181 """INSERT INTO users (ka_sub, email, name, picture, created_at, last_login)182 VALUES (?,?,?,?,?,?)183 ON CONFLICT(ka_sub) DO UPDATE SET184 email=excluded.email, name=excluded.name,185 picture=excluded.picture, last_login=excluded.last_login""",186 (ka_sub, email, name, picture, now, now))187 row = con.execute("SELECT id FROM users WHERE ka_sub=?", (ka_sub,)).fetchone()188 uid = row["id"]189 if ka_id:190 import sqlite3191 try:192 con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid))193 except sqlite3.IntegrityError:194 pass # KA ID déjà porté par une autre ligne locale : on garde195 con.commit()196 con.close()197198 session = _sign({199 "uid": uid,200 "ka_id": ka_id,201 "email": email,202 "name": name,203 "picture": picture,204 "exp": time.time() + SESSION_DAYS * 86400,205 })206 nxt = st.get("next") or ""207 resp = RedirectResponse(nxt if nxt.startswith("/") and not nxt.startswith("//")208 else "/?login=ok")209 resp.set_cookie(210 COOKIE, session,211 max_age=SESSION_DAYS * 86400,212 httponly=True,213 secure=_base_url(request).startswith("https"),214 samesite="lax",215 path="/",216 )217 return resp218219220@router.get("/me")221def me(request: Request):222 user = current_user(request)223 if user is None:224 raise HTTPException(401, "non connecté")225 con = db.connect()226 row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone()227 con.close()228 if row is None:229 raise HTTPException(401, "compte introuvable")230 return {231 "uid": row["id"],232 "ka_id": row["ka_id"],233 "email": row["email"],234 "name": row["name"],235 "picture": row["picture"],236 "created_at": row["created_at"],237 "last_login": row["last_login"],238 "provider": "ka-id",239 }240241242@router.post("/auth/logout")243def logout():244 resp = JSONResponse({"ok": True})245 resp.delete_cookie(COOKIE, path="/")246 return resp247