# Auteur : Simon-Pierre Boucher — contact@spboucher.ai # src/api/routes/iosauth.py — échange de jeton KA ID pour l'app iOS « KA ». # L'app obtient un ka_token (JWT HS256 du hub, aud="ka-ios") via # ASWebAuthenticationSession → POST /api/ios/auth/exchange : on VÉRIFIE la # signature côté serveur (le secret ne quitte jamais le serveur) puis on # enrichit avec le profil hub (requête signée HMAC), et on renvoie le profil. from __future__ import annotations import base64 import hashlib import hmac import json import os import time from typing import Any import httpx from fastapi import APIRouter, HTTPException, Request router = APIRouter(prefix="/api/ios/auth", tags=["ios-auth"]) HUB = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") CLIENT_ID = "ka-ios" CLIENT_IDS = {"ka-ios", "ka-android"} def _secret() -> bytes: s = os.environ.get("KA_IOS_SSO_SECRET", "") if not s: raise HTTPException(503, "KA_IOS_SSO_SECRET absent") return s.encode() def _b64d(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def _verify_jwt(token: str) -> dict[str, Any]: try: h64, p64, s64 = token.split(".") header = json.loads(_b64d(h64)) payload = json.loads(_b64d(p64)) if header.get("alg") != "HS256": raise ValueError("alg") expected = hmac.new(_secret(), f"{h64}.{p64}".encode(), hashlib.sha256).digest() if not hmac.compare_digest(expected, _b64d(s64)): raise ValueError("signature") if payload.get("aud") not in CLIENT_IDS: raise ValueError("aud") if payload.get("iss") != HUB: raise ValueError("iss") if float(payload.get("exp", 0)) < time.time(): raise ValueError("exp") return payload except HTTPException: raise except Exception as exc: raise HTTPException(401, f"jeton invalide ({exc})") async def _hub_profile(ka_id: str) -> dict[str, Any] | None: """Profil public/enrichi du hub (requête serveur-à-serveur signée HMAC).""" ts = str(int(time.time())) sig = hmac.new(_secret(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest() try: async with httpx.AsyncClient(timeout=8) as cx: r = await cx.get(f"{HUB}/api/sso/profile", params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig}) if r.status_code == 200: return r.json() except Exception: pass return None @router.post("/exchange") async def exchange(request: Request): body = await request.json() token = str(body.get("ka_token") or "") if not token: raise HTTPException(400, "ka_token manquant") claims = _verify_jwt(token) ka_id = claims.get("ka_id") or claims.get("sub") profile = await _hub_profile(str(ka_id)) if ka_id else None return { "ok": True, "ka_id": ka_id, "name": claims.get("name"), "email": claims.get("email"), "picture": claims.get("picture"), "provider": claims.get("provider"), "profile": profile, }