|
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 |
|