| |
1 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| |
2 | +# src/api/routes/iosauth.py — échange de jeton KA ID pour l'app iOS « KA ». |
| |
3 | +# L'app obtient un ka_token (JWT HS256 du hub, aud="ka-ios") via |
| |
4 | +# ASWebAuthenticationSession → POST /api/ios/auth/exchange : on VÉRIFIE la |
| |
5 | +# signature côté serveur (le secret ne quitte jamais le serveur) puis on |
| |
6 | +# enrichit avec le profil hub (requête signée HMAC), et on renvoie le profil. |
| |
7 | +from __future__ import annotations |
| |
8 | + |
| |
9 | +import base64 |
| |
10 | +import hashlib |
| |
11 | +import hmac |
| |
12 | +import json |
| |
13 | +import os |
| |
14 | +import time |
| |
15 | +from typing import Any |
| |
16 | + |
| |
17 | +import httpx |
| |
18 | +from fastapi import APIRouter, HTTPException, Request |
| |
19 | + |
| |
20 | +router = APIRouter(prefix="/api/ios/auth", tags=["ios-auth"]) |
| |
21 | + |
| |
22 | +HUB = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/") |
| |
23 | +CLIENT_ID = "ka-ios" |
| |
24 | + |
| |
25 | + |
| |
26 | +def _secret() -> bytes: |
| |
27 | + s = os.environ.get("KA_IOS_SSO_SECRET", "") |
| |
28 | + if not s: |
| |
29 | + raise HTTPException(503, "KA_IOS_SSO_SECRET absent") |
| |
30 | + return s.encode() |
| |
31 | + |
| |
32 | + |
| |
33 | +def _b64d(s: str) -> bytes: |
| |
34 | + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) |
| |
35 | + |
| |
36 | + |
| |
37 | +def _verify_jwt(token: str) -> dict[str, Any]: |
| |
38 | + try: |
| |
39 | + h64, p64, s64 = token.split(".") |
| |
40 | + header = json.loads(_b64d(h64)) |
| |
41 | + payload = json.loads(_b64d(p64)) |
| |
42 | + if header.get("alg") != "HS256": |
| |
43 | + raise ValueError("alg") |
| |
44 | + expected = hmac.new(_secret(), f"{h64}.{p64}".encode(), hashlib.sha256).digest() |
| |
45 | + if not hmac.compare_digest(expected, _b64d(s64)): |
| |
46 | + raise ValueError("signature") |
| |
47 | + if payload.get("aud") != CLIENT_ID: |
| |
48 | + raise ValueError("aud") |
| |
49 | + if payload.get("iss") != HUB: |
| |
50 | + raise ValueError("iss") |
| |
51 | + if float(payload.get("exp", 0)) < time.time(): |
| |
52 | + raise ValueError("exp") |
| |
53 | + return payload |
| |
54 | + except HTTPException: |
| |
55 | + raise |
| |
56 | + except Exception as exc: |
| |
57 | + raise HTTPException(401, f"jeton invalide ({exc})") |
| |
58 | + |
| |
59 | + |
| |
60 | +async def _hub_profile(ka_id: str) -> dict[str, Any] | None: |
| |
61 | + """Profil public/enrichi du hub (requête serveur-à-serveur signée HMAC).""" |
| |
62 | + ts = str(int(time.time())) |
| |
63 | + sig = hmac.new(_secret(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(), hashlib.sha256).hexdigest() |
| |
64 | + try: |
| |
65 | + async with httpx.AsyncClient(timeout=8) as cx: |
| |
66 | + r = await cx.get(f"{HUB}/api/sso/profile", |
| |
67 | + params={"client_id": CLIENT_ID, "ka_id": ka_id, "ts": ts, "sig": sig}) |
| |
68 | + if r.status_code == 200: |
| |
69 | + return r.json() |
| |
70 | + except Exception: |
| |
71 | + pass |
| |
72 | + return None |
| |
73 | + |
| |
74 | + |
| |
75 | +@router.post("/exchange") |
| |
76 | +async def exchange(request: Request): |
| |
77 | + body = await request.json() |
| |
78 | + token = str(body.get("ka_token") or "") |
| |
79 | + if not token: |
| |
80 | + raise HTTPException(400, "ka_token manquant") |
| |
81 | + claims = _verify_jwt(token) |
| |
82 | + ka_id = claims.get("ka_id") or claims.get("sub") |
| |
83 | + profile = await _hub_profile(str(ka_id)) if ka_id else None |
| |
84 | + return { |
| |
85 | + "ok": True, |
| |
86 | + "ka_id": ka_id, |
| |
87 | + "name": claims.get("name"), |
| |
88 | + "email": claims.get("email"), |
| |
89 | + "picture": claims.get("picture"), |
| |
90 | + "provider": claims.get("provider"), |
| |
91 | + "profile": profile, |
| |
92 | + } |
| |
93 | |