SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%

Connexion KA ID — « Se connecter avec KA » (SSO hub groupe-ka.com)

sortika/auth.py (patron louka/auth.py) : départ SSO avec state signé,
vérification du jeton HS256 du hub (issuer/audience/exp), upsert membre
(table users, KA ID adopté du hub), session cookie httpOnly HMAC,
/api/me avec profil dicté par le hub (/api/sso/profile). Bouton et puce
membre dans l'en-tête. 3 tests auth (22 au total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 17, 2026) parent 9164456

7 changed files +420 −5

modified README.md +7 −3
@@ -23,7 +23,8 @@
23 23 ![Connecteurs](https://img.shields.io/badge/connecteurs_actifs-8-1c5c41?style=flat-square)
24 24 ![Régions](https://img.shields.io/badge/r%C3%A9gions-17%2F17-1c5c41?style=flat-square)
25 25 ![Villes](https://img.shields.io/badge/villes-428-1c5c41?style=flat-square)
26 −![Tests](https://img.shields.io/badge/tests-19_pass-1c5c41?style=flat-square)
26 +![Tests](https://img.shields.io/badge/tests-22_pass-1c5c41?style=flat-square)
27 +![KA ID](https://img.shields.io/badge/connexion-KA_ID-141814?style=flat-square&logoColor=d9f26b)
27 28
28 29 *Agrégateur indépendant de sorties & événements — concerts, festivals, spectacles,
29 30 expositions, sport, famille — dans les 17 régions administratives du Québec, avec
@@ -79,6 +80,7 @@ architecture calquée sur le patron de référence Lou·Ka.
79 80 | **Dédup** | empreinte titre + ville + date : un même concert publié par 3 sources = **une carte**, la fiche la plus riche gagne | `schema.dedup_key()` + `web.py` |
80 81 | **API + SSR** | recherche/filtres JSON, fiches `/evenement/{uid}` servies avec `<head>` unique + **JSON-LD Event**, robots, sitemaps | `sortika/web.py` + `sortika/seo.py` |
81 82 | **Frontend** | SPA sans build, routeur History API, design system **Groupe KA « éditorial sharp »** | `frontend/index.html` |
83 +| **Comptes** | « Se connecter avec KA » — SSO **KA ID** du hub groupe-ka.com (jeton HS256, secret partagé, profil dicté par le hub) | `sortika/auth.py` |
82 84
83 85 ## Les 8 connecteurs actifs
84 86
@@ -100,13 +102,14 @@ Repentigny (2021) — consignées au registre `data/sources.json` avec leur stat
100 102
101 103 ```bash
102 104 python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
103 −.venv/bin/python -m pytest tests/ -q # 19 tests (fixtures réelles, zéro réseau)
105 +.venv/bin/python -m pytest tests/ -q # 22 tests (fixtures réelles, zéro réseau)
104 106 .venv/bin/python run.py sync # ingestion des 8 sources
105 107 .venv/bin/python run.py web --port 8120 # API + frontend + SSR
106 108 .venv/bin/python run.py watch --interval 3600 # boucle d'ingestion horaire
107 109 ```
108 110
109 −Secrets : `SCRAPFLY_API_KEY` dans `.env` (requis seulement pour Atuvu).
111 +Secrets `.env` : `SCRAPFLY_API_KEY` (Atuvu), `KA_SSO_SECRET` + `SESSION_SECRET` +
112 +`SORTIKA_BASE_URL` (connexion KA ID — secret partagé avec le hub groupe-ka.com).
110 113
111 114 ## API
112 115
@@ -115,6 +118,7 @@ Secrets : `SCRAPFLY_API_KEY` dans `.env` (requis seulement pour Atuvu).
115 118 | `GET /api/events` | recherche — `q`, `region`, `city`, `category`, `free`, `from`, `to`, `sort`, pagination ; dédupliquée inter-sources |
116 119 | `GET /api/events/{uid}` | fiche complète d'un événement |
117 120 | `GET /api/stats` | volumes par région et par catégorie |
121 +| `GET /api/auth/ka/login` · `/api/me` | connexion **KA ID** (SSO groupe-ka.com), profil membre |
118 122 | `GET /api/sources` | registre des sources + événements actifs + dernière synchro |
119 123 | `GET /evenement/{uid}` | fiche HTML SSR (title/og/canonical + **JSON-LD Event**) |
120 124 | `GET /sitemap.xml` · `/robots.txt` | référencement |
modified frontend/index.html +42 −0
@@ -98,6 +98,24 @@ img{display:block}
98 98 .nav a{padding:8px 14px;border:1.5px solid transparent;border-radius:var(--r-ctl);
99 99 font-family:var(--font-display);font-weight:700;font-size:13.5px;transition:all .15s ease}
100 100 .nav a:hover{border-color:var(--ink);background:var(--lime-soft)}
101 +.btn-ka{display:inline-flex;align-items:center;gap:8px;padding:8px 15px;min-height:38px;
102 + border:1.5px solid var(--ink);border-radius:var(--r-ctl);background:var(--ink);
103 + color:var(--lime);font-family:var(--font-display);font-weight:700;font-size:13px;
104 + cursor:pointer;box-shadow:3px 3px 0 rgba(20,24,20,.25);transition:background .15s ease}
105 +.btn-ka:hover{background:var(--green-deep)}
106 +.btn-ka .kdot{background:var(--lime);color:var(--ink);border-radius:4px;
107 + padding:0 5px;font-weight:800;transform:rotate(-1deg)}
108 +.user-chip{display:inline-flex;align-items:center;gap:9px;border:1.5px solid var(--ink);
109 + border-radius:var(--r-pill,999px);background:var(--surface);padding:4px 12px 4px 5px;
110 + box-shadow:3px 3px 0 rgba(20,24,20,.12)}
111 +.user-chip img{width:28px;height:28px;border-radius:50%;border:1.5px solid var(--ink);object-fit:cover}
112 +.user-chip .uname{font-family:var(--font-display);font-weight:700;font-size:12.5px;max-width:140px;
113 + overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
114 +.user-chip .kaid{font-family:var(--font-mono);font-size:9px;color:var(--ink-3);display:block;line-height:1}
115 +.user-chip .out{border:none;background:none;cursor:pointer;font-family:var(--font-mono);
116 + font-size:9.5px;font-weight:700;text-transform:uppercase;color:var(--danger);padding:2px}
117 +.user-chip .out:hover{text-decoration:underline}
118 +@media(max-width:640px){.user-chip .uname{max-width:80px}}
101 119
102 120 /* ---------- hero ---------- */
103 121 .hero{padding:56px 0 20px}
@@ -245,6 +263,7 @@ footer a.mail{color:var(--lime)}
245 263 <a href="/#sources">Sources</a>
246 264 <a href="/docs" target="_blank" rel="noopener">API</a>
247 265 </nav>
266 + <span id="auth"></span>
248 267 </div>
249 268 </header>
250 269
@@ -569,6 +588,29 @@ async function init(){
569 588 </div>`).join("");
570 589 }catch(e){}
571 590
591 + // --- KA ID (« Se connecter avec KA » — hub groupe-ka.com) ---
592 + try{
593 + const cfg = await (await fetch("/api/auth/config")).json();
594 + if(cfg.ka){
595 + const meRes = await fetch("/api/me");
596 + if(meRes.ok){
597 + const me = await meRes.json();
598 + const pic = me.picture ? `<img src="${esc(me.picture)}" alt="" onerror="this.remove()">` : "";
599 + $("auth").innerHTML = `<span class="user-chip">${pic}
600 + <span><span class="uname">${esc(me.name||me.email||"Membre")}</span>
601 + <span class="kaid">${esc(me.ka_id||"")}</span></span>
602 + <button class="out" id="logout">Sortir</button></span>`;
603 + $("logout").onclick = async () => {
604 + await fetch("/api/auth/logout", {method:"POST"});
605 + location.href = "/";
606 + };
607 + } else {
608 + $("auth").innerHTML = `<a class="btn-ka" href="/api/auth/ka/login">
609 + <span class="kdot">·Ka</span> Se connecter avec KA</a>`;
610 + }
611 + }
612 + }catch(e){}
613 +
572 614 let t; $("q").addEventListener("input", e=>{
573 615 clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); load(); }, 280);
574 616 });
modified sortika/__init__.py +22 −1
@@ -3,7 +3,28 @@
3 3 # Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 4 # __init__.py : paquet sortika — pipeline connecteurs → normalisation →
5 5 # déduplication → SQLite → API FastAPI (famille ·Ka, patron Lou-Ka)
6 +# + chargement de .env (secrets SSO KA ID, Scrapfly, session)
6 7 # -----------------------------------------------------------------------------
7 8 """Sorti-Ka — toutes les sorties et tous les événements du Québec, un seul endroit."""
9 +from __future__ import annotations
8 10
9 −__version__ = "1.0.0"
11 +import os
12 +from pathlib import Path
13 +
14 +__version__ = "1.1.0"
15 +
16 +
17 +def _load_env() -> None:
18 + """Charge .env à la racine du projet, sans dépendance externe."""
19 + env_path = Path(__file__).resolve().parent.parent / ".env"
20 + if not env_path.exists():
21 + return
22 + for line in env_path.read_text(encoding="utf-8").splitlines():
23 + line = line.strip()
24 + if not line or line.startswith("#") or "=" not in line:
25 + continue
26 + k, v = line.split("=", 1)
27 + os.environ.setdefault(k.strip(), v.strip())
28 +
29 +
30 +_load_env()
added sortika/auth.py +268 −0
@@ -0,0 +1,268 @@
1 +# -----------------------------------------------------------------------------
2 +# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# auth.py : comptes membres — « Se connecter avec KA » (KA ID, hub d'identité
5 +# du Groupe KA — groupe-ka.com), patron Lou-Ka : louka/auth.py.
6 +# Flux SSO :
7 +# GET /api/auth/ka/login -> redirige vers le hub (state signé)
8 +# GET /api/auth/ka/callback -> vérifie le jeton HS256 du hub (secret
9 +# partagé KA_SSO_SECRET), upsert le membre,
10 +# pose le cookie de session, redirige /
11 +# GET /api/me -> profil de la session (hub = source de
12 +# vérité du profil via /api/sso/profile)
13 +# POST /api/auth/logout -> efface le cookie
14 +# GET /api/auth/config -> {"ka": bool} (bouton affiché ou non)
15 +# Session : jeton signé HMAC-SHA256 (stdlib seulement) dans un cookie
16 +# httpOnly. Config .env : KA_SSO_SECRET (partagé avec le hub),
17 +# SESSION_SECRET, SORTIKA_BASE_URL, KA_HUB_URL (optionnel).
18 +# -----------------------------------------------------------------------------
19 +from __future__ import annotations
20 +
21 +import base64
22 +import hashlib
23 +import hmac
24 +import json
25 +import os
26 +import secrets
27 +import threading
28 +import time
29 +from urllib.parse import urlencode
30 +
31 +import requests
32 +from fastapi import APIRouter, HTTPException, Request
33 +from fastapi.responses import JSONResponse, RedirectResponse
34 +
35 +from . import db
36 +
37 +router = APIRouter(prefix="/api")
38 +
39 +CLIENT_ID = "sorti-ka"
40 +COOKIE = "sortika_session"
41 +SESSION_DAYS = 30
42 +KA_HUB_URL = os.environ.get("KA_HUB_URL", "https://www.groupe-ka.com").rstrip("/")
43 +
44 +
45 +def _secret() -> bytes:
46 + s = os.environ.get("SESSION_SECRET")
47 + if not s:
48 + raise HTTPException(503, "SESSION_SECRET manquant (voir .env)")
49 + return s.encode()
50 +
51 +
52 +def _ka_secret() -> bytes | None:
53 + s = os.environ.get("KA_SSO_SECRET")
54 + return s.encode() if s else None
55 +
56 +
57 +def _base_url(request: Request) -> str:
58 + """URL publique de l'app — forcée par SORTIKA_BASE_URL en prod (ngrok)."""
59 + return (os.environ.get("SORTIKA_BASE_URL")
60 + or str(request.base_url)).rstrip("/")
61 +
62 +
63 +# -- jeton de session signé (payload JSON -> base64url.signature) ---------------
64 +
65 +def _sign(payload: dict) -> str:
66 + raw = base64.urlsafe_b64encode(
67 + json.dumps(payload, separators=(",", ":")).encode()).decode().rstrip("=")
68 + sig = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
69 + return f"{raw}.{sig}"
70 +
71 +
72 +def _verify(token: str) -> dict | None:
73 + try:
74 + raw, sig = token.rsplit(".", 1)
75 + expected = hmac.new(_secret(), raw.encode(), hashlib.sha256).hexdigest()
76 + if not hmac.compare_digest(sig, expected):
77 + return None
78 + payload = json.loads(base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)))
79 + if payload.get("exp", 0) < time.time():
80 + return None
81 + return payload
82 + except Exception:
83 + return None
84 +
85 +
86 +def current_user(request: Request) -> dict | None:
87 + """Payload de session ({uid, ka_id, email, name, picture}) ou None."""
88 + token = request.cookies.get(COOKIE)
89 + return _verify(token) if token else None
90 +
91 +
92 +# -- vérification du jeton du hub (JWT HS256, stdlib seulement) ------------------
93 +
94 +def _b64url(s: str) -> bytes:
95 + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
96 +
97 +
98 +def _verify_ka_token(token: str) -> dict | None:
99 + secret = _ka_secret()
100 + if not secret:
101 + return None
102 + try:
103 + h_b64, p_b64, s_b64 = token.split(".")
104 + expected = hmac.new(secret, f"{h_b64}.{p_b64}".encode(),
105 + hashlib.sha256).digest()
106 + if not hmac.compare_digest(_b64url(s_b64), expected):
107 + return None
108 + if json.loads(_b64url(h_b64)).get("alg") != "HS256":
109 + return None
110 + claims = json.loads(_b64url(p_b64))
111 + if claims.get("iss") != KA_HUB_URL:
112 + return None
113 + aud = claims.get("aud")
114 + if (aud if isinstance(aud, str) else "") != CLIENT_ID \
115 + and CLIENT_ID not in (aud if isinstance(aud, list) else []):
116 + return None
117 + if claims.get("exp", 0) < time.time():
118 + return None
119 + return claims
120 + except Exception:
121 + return None
122 +
123 +
124 +# -- profil membre au hub (source de vérité — groupe-ka.com/compte) --------------
125 +# GET {hub}/api/sso/profile?client_id=sorti-ka&ka_id=…&ts=…&sig=…
126 +# sig = HMAC-SHA256(KA_SSO_SECRET, "sorti-ka.<ka_id>.<ts>") hex.
127 +# Cache mémoire 60 s ; None sur toute erreur -> repli sur les champs locaux.
128 +
129 +_profile_cache: dict[str, tuple[float, dict | None]] = {}
130 +_profile_lock = threading.Lock()
131 +
132 +
133 +def fetch_hub_profile(ka_id: str) -> dict | None:
134 + secret = os.environ.get("KA_SSO_SECRET")
135 + if not secret or not ka_id:
136 + return None
137 + now = time.time()
138 + with _profile_lock:
139 + hit = _profile_cache.get(ka_id)
140 + if hit and now - hit[0] < 60:
141 + return hit[1]
142 + data: dict | None = None
143 + try:
144 + ts = int(now)
145 + sig = hmac.new(secret.encode(), f"{CLIENT_ID}.{ka_id}.{ts}".encode(),
146 + hashlib.sha256).hexdigest()
147 + r = requests.get(f"{KA_HUB_URL}/api/sso/profile",
148 + params={"client_id": CLIENT_ID, "ka_id": ka_id,
149 + "ts": ts, "sig": sig}, timeout=5)
150 + if r.status_code == 200:
151 + data = r.json()
152 + if not isinstance(data, dict):
153 + return None
154 + elif r.status_code != 404:
155 + return None # erreur transitoire : pas de mise en cache
156 + except Exception:
157 + return None
158 + with _profile_lock:
159 + _profile_cache[ka_id] = (now, data)
160 + return data
161 +
162 +
163 +# -- routes ----------------------------------------------------------------------
164 +
165 +@router.get("/auth/config")
166 +def auth_config():
167 + return {"ka": bool(_ka_secret() and os.environ.get("SESSION_SECRET"))}
168 +
169 +
170 +@router.get("/auth/ka/login")
171 +def ka_login(request: Request):
172 + """Départ SSO : envoie l'utilisateur au hub KA ID (groupe-ka.com)."""
173 + if not _ka_secret():
174 + raise HTTPException(503, "KA_SSO_SECRET manquant (voir .env)")
175 + state = _sign({"n": secrets.token_urlsafe(12), "exp": time.time() + 600})
176 + params = {
177 + "client_id": CLIENT_ID,
178 + "redirect_uri": f"{_base_url(request)}/api/auth/ka/callback",
179 + "state": state,
180 + }
181 + return RedirectResponse(f"{KA_HUB_URL}/sso/authorize?{urlencode(params)}")
182 +
183 +
184 +@router.get("/auth/ka/callback")
185 +def ka_callback(request: Request, ka_token: str = "", state: str = ""):
186 + """Retour SSO : vérifie le jeton du hub, upsert le membre local,
187 + pose le cookie de session Sorti-Ka."""
188 + if not ka_token or _verify(state) is None:
189 + raise HTTPException(400, "state invalide ou expiré")
190 + claims = _verify_ka_token(ka_token)
191 + if claims is None:
192 + raise HTTPException(401, "jeton KA invalide ou expiré")
193 +
194 + email = (claims.get("email") or "").lower()
195 + name = claims.get("name") or (email.split("@")[0] if email else "membre")
196 + picture = claims.get("picture") or ""
197 + hub_sub = f"ka:{claims.get('sub')}"
198 + ka_id = (claims.get("ka_id") or "").strip()
199 +
200 + con = db.connect()
201 + now = time.time()
202 + con.execute(
203 + """INSERT INTO users (hub_sub, email, name, picture, created_at, last_login)
204 + VALUES (?,?,?,?,?,?)
205 + ON CONFLICT(hub_sub) DO UPDATE SET
206 + email=excluded.email, name=excluded.name,
207 + picture=excluded.picture, last_login=excluded.last_login""",
208 + (hub_sub, email, name, picture, now, now))
209 + row = con.execute("SELECT id FROM users WHERE hub_sub=?", (hub_sub,)).fetchone()
210 + uid = row["id"]
211 + if ka_id: # le KA ID est créé à la source par le hub — on l'adopte tel quel
212 + import sqlite3
213 + try:
214 + con.execute("UPDATE users SET ka_id=? WHERE id=?", (ka_id, uid))
215 + except sqlite3.IntegrityError:
216 + pass
217 + con.commit()
218 + con.close()
219 +
220 + session = _sign({
221 + "uid": uid, "ka_id": ka_id, "email": email, "name": name,
222 + "picture": picture, "exp": time.time() + SESSION_DAYS * 86400,
223 + })
224 + resp = RedirectResponse("/?login=ok")
225 + resp.set_cookie(COOKIE, session, max_age=SESSION_DAYS * 86400,
226 + httponly=True,
227 + secure=_base_url(request).startswith("https"),
228 + samesite="lax", path="/")
229 + return resp
230 +
231 +
232 +@router.get("/me")
233 +def me(request: Request):
234 + user = current_user(request)
235 + if user is None:
236 + raise HTTPException(401, "non connecté")
237 + con = db.connect()
238 + row = con.execute("SELECT * FROM users WHERE id=?", (user["uid"],)).fetchone()
239 + con.close()
240 + if row is None:
241 + raise HTTPException(401, "compte introuvable")
242 + out = {
243 + "uid": row["id"], "ka_id": row["ka_id"] or "",
244 + "email": row["email"], "name": row["name"],
245 + "picture": row["picture"], "created_at": row["created_at"],
246 + "last_login": row["last_login"], "provider": "ka-id",
247 + "profile_source": "local",
248 + }
249 + # le hub Groupe KA est la source de vérité du profil (édition sur
250 + # groupe-ka.com/compte) : ses champs remplacent les champs locaux
251 + hub = fetch_hub_profile(out["ka_id"])
252 + if hub is not None:
253 + if (hub.get("name") or "").strip():
254 + out["name"] = hub["name"].strip()
255 + if hub.get("picture"):
256 + out["picture"] = hub["picture"]
257 + out.update({"bio": hub.get("bio") or "", "city": hub.get("city") or "",
258 + "website": hub.get("website") or "",
259 + "public_url": hub.get("public_url") or "",
260 + "profile_source": "groupe-ka"})
261 + return out
262 +
263 +
264 +@router.post("/auth/logout")
265 +def logout():
266 + resp = JSONResponse({"ok": True})
267 + resp.delete_cookie(COOKIE, path="/")
268 + return resp
modified sortika/db.py +11 −0
@@ -68,6 +68,17 @@ CREATE INDEX IF NOT EXISTS idx_events_region ON events(region);
68 68 CREATE INDEX IF NOT EXISTS idx_events_start ON events(start_date);
69 69 CREATE INDEX IF NOT EXISTS idx_events_dedup ON events(dedup_key);
70 70
71 +CREATE TABLE IF NOT EXISTS users (
72 + id INTEGER PRIMARY KEY AUTOINCREMENT,
73 + hub_sub TEXT UNIQUE, -- clé stable du hub KA ("ka:<sub>")
74 + ka_id TEXT UNIQUE, -- identifiant membre du groupe (hub)
75 + email TEXT,
76 + name TEXT,
77 + picture TEXT,
78 + created_at REAL,
79 + last_login REAL
80 +);
81 +
71 82 CREATE TABLE IF NOT EXISTS sync_log (
72 83 id INTEGER PRIMARY KEY AUTOINCREMENT,
73 84 source TEXT NOT NULL,
modified sortika/web.py +4 −1
@@ -16,7 +16,7 @@ from fastapi.middleware.gzip import GZipMiddleware
16 16 from fastapi.responses import FileResponse
17 17 from fastapi.staticfiles import StaticFiles
18 18
19 −from . import db, seo
19 +from . import auth, db, seo
20 20 from .normalize import CATEGORIES
21 21 from .regions import REGIONS
22 22
@@ -194,6 +194,9 @@ def sources() -> dict:
194 194 return reg
195 195
196 196
197 +# --- comptes membres : « Se connecter avec KA » (KA ID) — sortika/auth.py ---
198 +app.include_router(auth.router)
199 +
197 200 # --- SSR SEO : fiches /evenement/{uid}, robots.txt, sitemaps (sortika/seo.py) ---
198 201 app.include_router(seo.router)
199 202
added tests/test_auth.py +66 −0
@@ -0,0 +1,66 @@
1 +# ==============================================================================
2 +# Author: Simon-Pierre Boucher <contact@spboucher.ai>
3 +# File: tests/test_auth.py
4 +# Desc: Tests de la connexion KA ID — session signée HMAC, vérification du
5 +# jeton HS256 du hub (issuer, audience, expiration, signature).
6 +# ==============================================================================
7 +from __future__ import annotations
8 +
9 +import base64
10 +import hashlib
11 +import hmac
12 +import json
13 +import time
14 +
15 +import pytest
16 +
17 +from sortika import auth
18 +
19 +
20 +@pytest.fixture(autouse=True)
21 +def _secrets(monkeypatch):
22 + monkeypatch.setenv("SESSION_SECRET", "test-session-secret")
23 + monkeypatch.setenv("KA_SSO_SECRET", "test-ka-secret")
24 +
25 +
26 +def _hub_jwt(claims: dict, secret: str = "test-ka-secret") -> str:
27 + def b64(d: dict) -> str:
28 + return base64.urlsafe_b64encode(
29 + json.dumps(d).encode()).decode().rstrip("=")
30 + h, p = b64({"alg": "HS256", "typ": "JWT"}), b64(claims)
31 + sig = base64.urlsafe_b64encode(
32 + hmac.new(secret.encode(), f"{h}.{p}".encode(),
33 + hashlib.sha256).digest()).decode().rstrip("=")
34 + return f"{h}.{p}.{sig}"
35 +
36 +
37 +def _claims(**over) -> dict:
38 + base = {"iss": auth.KA_HUB_URL, "aud": "sorti-ka",
39 + "sub": "42", "ka_id": "ka-0123456789",
40 + "email": "membre@exemple.com", "name": "Membre Test",
41 + "exp": time.time() + 300}
42 + base.update(over)
43 + return base
44 +
45 +
46 +def test_session_sign_verify():
47 + tok = auth._sign({"uid": 1, "exp": time.time() + 60})
48 + assert auth._verify(tok)["uid"] == 1
49 + assert auth._verify(tok + "x") is None
50 + assert auth._verify(auth._sign({"uid": 1, "exp": time.time() - 1})) is None
51 +
52 +
53 +def test_hub_token_valid():
54 + claims = auth._verify_ka_token(_hub_jwt(_claims()))
55 + assert claims and claims["ka_id"] == "ka-0123456789"
56 +
57 +
58 +def test_hub_token_rejections():
59 + # mauvaise signature
60 + assert auth._verify_ka_token(_hub_jwt(_claims(), secret="autre")) is None
61 + # mauvais émetteur
62 + assert auth._verify_ka_token(_hub_jwt(_claims(iss="https://evil.com"))) is None
63 + # mauvaise audience (jeton émis pour lou-ka)
64 + assert auth._verify_ka_token(_hub_jwt(_claims(aud="lou-ka"))) is None
65 + # expiré
66 + assert auth._verify_ka_token(_hub_jwt(_claims(exp=time.time() - 10))) is None
67