From f087b41b4bebc0cdb3122c9e94ea2c6b8434a695 Mon Sep 17 00:00:00 2001 From: Spark Date: Wed, 21 Oct 2026 10:56:22 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Fusion=20=E2=80=94=20barre=20d'apps=20c?= =?UTF-8?q?ommune=20(#28)=20et=20pont=20SSO=20draco=5Fsession=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 21 ++++++ app/config.py | 19 ++++- app/draco.py | 124 +++++++++++++++++++++++++++++++++ app/main.py | 33 ++++++++- app/templates/_draco_bar.html | 98 ++++++++++++++++++++++++++ app/templates/_header.html | 1 + app/templates/login.html | 1 + pyproject.toml | 2 +- static/css/draco-barre.css | 127 ++++++++++++++++++++++++++++++++++ static/css/style.css | 14 ++++ static/js/draco-barre.js | 56 +++++++++++++++ tests/test_draco_barre.py | 39 +++++++++++ tests/test_draco_sso.py | 103 +++++++++++++++++++++++++++ 13 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 app/draco.py create mode 100644 app/templates/_draco_bar.html create mode 100644 static/css/draco-barre.css create mode 100644 static/js/draco-barre.js create mode 100644 tests/test_draco_barre.py create mode 100644 tests/test_draco_sso.py diff --git a/CHANGELOG.md b/CHANGELOG.md index deb4fe4..51876b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.23.0 — 2026-10-21 + +### Fusion — Kanbante rejoint l'accès centralisé (cartes #27, #28) + +- **Barre d'apps commune dracodev** (ACC-1, #28) : snippet figé + vendangé de dracodev-commons v0.1.1 (partiel `_draco_bar.html`, + `draco-barre.css` / `draco-barre.js`), inclus par le cadre partagé + — Kanbante était la dernière des quatre applications sans la barre. + Le rail pilote le repli natif de la barre latérale, la palette + ouvre la recherche & commandes native ; alertes unifiées et Spark + annoncent leur arrivée avec la fédération. +- **Pont SSO `draco_session`** (ACC-3.2, #27) : le cookie émis par + ObsiGate est vérifié localement (`app/draco.py` — JWS HS256 au + secret partagé `DRACO_SESSION_SECRET`, révocation de l'émettrice + en cache 60 s) et converti en session Kanbante native par + `GET /auth/draco` ; bouton « Se connecter avec ObsiGate (SSO) » + sur l'écran de connexion. Kanbante étant mono-utilisateur, aucun + mapping de comptes n'est nécessaire. Inerte sans le secret. +- Tests : `test_draco_sso.py` (7) et `test_draco_barre.py` (3) ; + suite complète : 211 tests verts. + ## 0.22.1 — 2026-10-20 ### Correctif — colonne Terminé vide diff --git a/app/config.py b/app/config.py index cddb2b4..d9c86e6 100644 --- a/app/config.py +++ b/app/config.py @@ -14,6 +14,15 @@ Environment variables: KANBANTE_SESSION_SECRET HMAC key for the login session cookie (default: derived from the credentials — rotating the password invalidates sessions). + DRACO_SESSION_SECRET Shared secret of the dracodev SSO cookie + (``draco_session``, emitted by ObsiGate — + Fusion ACC-3). Empty (default) = SSO bridge + disabled. ``DRACO_SESSION_SECRET_PREV`` + accepts the previous secret while rotating, + ``DRACO_SSO_MACHINE_KEY`` authenticates the + revocation-list fetch and ``DRACO_SSO_URL`` + points at the emitter (default + https://og.dracodev.net). KANBANTE_SNAPSHOT_INTERVAL Seconds between background snapshot refreshes (chantier B, default 120; 0 disables the refresher). @@ -95,7 +104,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.22.1" +APP_VERSION = "0.23.0" def _env_bool(name: str, default: bool) -> bool: @@ -166,6 +175,10 @@ class Settings: zombie_days: int = 30 session_secret: str = "" snapshot_interval: int = 120 + draco_secret: str = "" + draco_secret_prev: str = "" + sso_machine_key: str = "" + sso_url: str = "https://og.dracodev.net" @property def auth_enabled(self) -> bool: @@ -213,4 +226,8 @@ def get_settings() -> Settings: zombie_days=_env_int("KANBANTE_ZOMBIE_DAYS", 30), session_secret=os.getenv("KANBANTE_SESSION_SECRET", ""), snapshot_interval=_env_int("KANBANTE_SNAPSHOT_INTERVAL", 120), + draco_secret=os.getenv("DRACO_SESSION_SECRET", ""), + draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""), + sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""), + sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"), ) diff --git a/app/draco.py b/app/draco.py new file mode 100644 index 0000000..5ede1d1 --- /dev/null +++ b/app/draco.py @@ -0,0 +1,124 @@ +"""Validation locale du cookie SSO ``draco_session`` — Fusion ACC-3.2. + +Kanbante valide localement le jeton émis par ObsiGate (l'émettrice, +ACC-3.1) : JWS HS256 vérifié avec le secret partagé +(``DRACO_SESSION_SECRET``, ``_PREV`` accepté en rotation), +algorithme épinglé, expiration, et liste de révocation de +l'émettrice (cache 60 s, interrogée seulement si un cookie draco +est présenté). Vérification hmac/hashlib — mêmes sémantiques que +les validateurs de TikTak (0.52.0) et Flowdeck (7.74.0). + +Le pont ``GET /auth/draco`` (app/main.py) convertit un cookie valide +en session Kanbante native. Kanbante est mono-utilisateur +(identifiants configurés par l'environnement) : un jeton valide +désigne l'opérateur configuré — pas de table de liens. Sans secret +configuré, module inerte. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import time + +from app.config import get_settings + +logger = logging.getLogger(__name__) + +COOKIE_NAME = "draco_session" +_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design + +_revoked_cache: dict[str, int] = {} +_revoked_fetched_at: float = 0.0 + + +def enabled() -> bool: + return bool(get_settings().draco_secret) + + +def _b64decode(segment: str) -> bytes: + return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) + + +def verify_token(token: str) -> dict | None: + """Vérifie un JWS HS256 (signature, alg épinglé, expiration).""" + settings = get_settings() + if not token or not settings.draco_secret: + return None + parts = token.split(".") + if len(parts) != 3: + return None + try: + header = json.loads(_b64decode(parts[0])) + if not isinstance(header, dict) or header.get("alg") != "HS256": + return None + signing_input = f"{parts[0]}.{parts[1]}".encode("ascii") + signature = _b64decode(parts[2]) + claims = json.loads(_b64decode(parts[1])) + except Exception: + return None + if not isinstance(claims, dict): + return None + for secret in (settings.draco_secret, settings.draco_secret_prev): + if not secret: + continue + attendu = hmac.new(secret.encode("utf-8"), signing_input, + hashlib.sha256).digest() + if hmac.compare_digest(attendu, signature): + break + else: + return None + try: + if int(claims.get("exp", 0)) <= int(time.time()): + return None + except (TypeError, ValueError): + return None + return claims + + +# ── Révocations (cache 60 s alimenté par l'émettrice) ─────────────────── + +def _refresh_revocations() -> None: + global _revoked_fetched_at + now = time.monotonic() + if now - _revoked_fetched_at < _REVOCATIONS_TTL: + return + _revoked_fetched_at = now + settings = get_settings() + try: + import httpx + + headers = {} + if settings.sso_machine_key: + headers["X-Draco-Machine-Key"] = settings.sso_machine_key + r = httpx.get(f"{settings.sso_url}/api/sso/revocations", + headers=headers, timeout=2.0) + if r.status_code == 200: + _revoked_cache.clear() + for item in r.json().get("revoked", []): + _revoked_cache[str(item["jti"])] = int(item["exp"]) + except Exception as exc: # l'émettrice injoignable ne bloque pas le SSO + logger.debug("Révocations draco indisponibles: %s", exc) + + +def is_revoked(jti: str) -> bool: + _refresh_revocations() + return jti in _revoked_cache + + +def claims_for(request) -> dict | None: + """Claims valides du cookie draco de la requête (révocation incluse).""" + if not enabled(): + return None + claims = verify_token(request.cookies.get(COOKIE_NAME, "")) + if not claims: + return None + jti = str(claims.get("jti", "")) + if jti and is_revoked(jti): + return None + if not claims.get("sub"): + return None + return claims diff --git a/app/main.py b/app/main.py index 82e2c70..6383356 100644 --- a/app/main.py +++ b/app/main.py @@ -220,7 +220,8 @@ gitea = GiteaClient() SESSION_COOKIE = "kanbante_session" SESSION_DAYS = 7 -_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login") +_PUBLIC_PATHS = ("/api/health", "/webhooks/gitea", "/api/capture", "/login", + "/auth/draco") def _session_secret() -> bytes: @@ -374,6 +375,32 @@ async def logout(): return response +@app.get("/auth/draco") +def draco_bridge(request: Request, next: str = "/"): + """Fusion ACC-3.2 — pont SSO : cookie ``draco_session`` → session. + + Point de retour de ``GET /sso/authorize`` de l'émettrice + (ObsiGate) : si le cookie partagé est valide, la session native + de l'opérateur configuré est créée (Kanbante est mono-utilisateur, + aucun mapping de comptes n'est nécessaire) ; sinon, retour à + l'écran de connexion. Inerte sans ``DRACO_SESSION_SECRET``. + """ + from app import draco + + settings = get_settings() + if not settings.auth_enabled or draco.claims_for(request) is None: + return RedirectResponse("/login", status_code=303) + response = RedirectResponse(_safe_next(next), status_code=303) + response.set_cookie( + SESSION_COOKIE, + _make_session(settings.auth_user), + max_age=SESSION_DAYS * 86400, + httponly=True, + samesite="lax", + ) + return response + + # ── Live updates (SSE hub) ───────────────────────────────── class LiveHub: @@ -459,6 +486,10 @@ def render( token = secrets.token_urlsafe(32) context.setdefault("csrf_token", token) context.setdefault("version", APP_VERSION) + # Fusion ACC-1 : initiale de l'opérateur pour la barre d'apps commune. + context.setdefault( + "draco_avatar", (get_settings().auth_user or "K")[:1].upper() + ) context.setdefault("active_nav", _NAV_BY_TEMPLATE.get(name, "")) context.setdefault("auth_enabled", get_settings().auth_enabled) context.setdefault("data_freshness", snapshots_service.freshness_label()) diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html new file mode 100644 index 0000000..46b748a --- /dev/null +++ b/app/templates/_draco_bar.html @@ -0,0 +1,98 @@ +{# Barre d'apps commune dracodev — Fusion ACC-1 (issue kanbante#28). + Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.1 + (ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution + passe par une nouvelle version de commons. + CSS/JS : /static/css/draco-barre.css, /static/js/draco-barre.js. + Inclus par _header.html (toutes les pages applicatives ; ni le + wallboard ni l'écran de connexion, volontaires). #} + + +
+ +
+ +
+
+
+ Domaine + + + +
+ + + + {{ draco_avatar }} +
+
+ + + diff --git a/app/templates/_header.html b/app/templates/_header.html index 3079bb1..becafe5 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -1,3 +1,4 @@ +{% include "_draco_bar.html" %} {# ── Cadre applicatif partagé (issue #16, refonte 0.16.0) ───────── Barre latérale unique pour TOUTES les pages applicatives : desktop = barre fixe repliable en rail d'icônes (état mémorisé), diff --git a/app/templates/login.html b/app/templates/login.html index fc7945a..7f2a145 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -29,6 +29,7 @@ + 🧭 Se connecter avec ObsiGate (SSO)

Accès restreint — session de {{ 7 }} jours, déconnexion depuis le menu latéral.

diff --git a/pyproject.toml b/pyproject.toml index 944c570..5ae3f09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.22.1" +version = "0.23.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/static/css/draco-barre.css b/static/css/draco-barre.css new file mode 100644 index 0000000..02b1488 --- /dev/null +++ b/static/css/draco-barre.css @@ -0,0 +1,127 @@ +/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) + Jetons repris du prototype validé Console v7.2. + Thème : clair par défaut, sombre via [data-theme="dark"] sur + ou prefers-color-scheme si l'app intégratrice ne force rien. */ + +.draco-bar { + --bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3; + --ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9; + --agent: #c2437e; --danger: #cf4444; + height: 60px; box-sizing: border-box; + display: grid; grid-template-columns: auto minmax(200px, 520px) auto; + align-items: center; gap: 16px; padding: 0 12px; + background: var(--surface); border-bottom: 1px solid var(--line); + font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink); +} +@media (prefers-color-scheme: dark) { + .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f; + --ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; } +} +[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f; + --ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; } + +.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; } +.draco-bar-centre { justify-content: center; } +.draco-bar-droite { justify-content: flex-end; } + +/* Marque */ +.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; } +.draco-brand-mark { color: var(--agent); } + +/* Onglets d'apps — accordéon : seule l'app courante est déployée */ +.draco-apptabs { display: flex; gap: 6px; } +.draco-apptab { + position: relative; display: flex; align-items: center; gap: 8px; + height: 44px; padding: 0 9px; border-radius: 12px; + color: var(--ink); text-decoration: none; background: transparent; + transition: background .18s ease; +} +.draco-apptab:hover { background: var(--surface-2); } +.draco-apptab-ico { + width: 30px; height: 30px; flex: none; display: grid; place-items: center; + border-radius: 9px; background: var(--app); color: #fff; font-size: 15px; +} +.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; } +.draco-apptab-txt strong { font-size: 14px; font-weight: 700; } +.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); } +.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); } +.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; } + +/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */ +.draco-badge { + position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px; + padding: 0 4px; border-radius: 9px; box-sizing: border-box; + background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700; + display: grid; place-items: center; border: 2px solid var(--surface); +} +.draco-badge[hidden] { display: none; } + +/* Recherche */ +.draco-search { + display: flex; align-items: center; gap: 8px; width: 100%; height: 38px; + padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); + background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit; +} +.draco-search kbd { + margin-left: auto; font: 600 11px system-ui; color: var(--ink-2); + border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px; +} + +/* Domaines — contrôle segmenté */ +.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; } +.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; } +.draco-domaines button { + border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px; + height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer; +} +.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); } + +/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */ +.draco-alertes { + position: relative; display: flex; align-items: center; gap: 7px; height: 38px; + padding: 0 11px; border-radius: 10px; border: 1px solid var(--line); + background: transparent; color: var(--ink); font: inherit; cursor: pointer; +} + +/* Spark */ +.draco-spark { + position: relative; display: flex; align-items: center; gap: 7px; height: 38px; + padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer; + background: var(--agent); color: #fff; font: inherit; font-weight: 700; +} +.draco-spark .draco-badge { border-color: var(--agent); } + +.draco-icon-btn { + width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent; + background: transparent; color: var(--ink-2); font: inherit; cursor: pointer; +} +.draco-icon-btn:hover { background: var(--surface-2); } +.draco-avatar { + width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; + background: #2f6fed; color: #fff; font-weight: 700; +} + +/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */ +.draco-bar-rappel { + position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer; + background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0; + border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2); +} + +/* Paliers — les zones latérales cèdent, la barre ne casse jamais */ +@media (max-width: 1440px) { + .draco-brand-name, .draco-domaines-lbl { display: none; } + .draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; } +} +@media (max-width: 1200px) { + .draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; } + .draco-search { width: 38px; padding: 0; justify-content: center; } + .draco-bar { grid-template-columns: auto auto 1fr; } + .draco-bar-droite { justify-content: flex-end; } +} +@media (max-width: 700px) { + /* Mobile : la barre du haut se condense ; la navigation d'apps passe + en barre basse propre à chaque app (patron du prototype v5+). */ + .draco-apptabs, .draco-domaines-lbl { display: none; } + .draco-bar { height: 52px; } +} diff --git a/static/css/style.css b/static/css/style.css index 521be3e..7dd7618 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1607,4 +1607,18 @@ a.ms-title:hover { text-decoration: underline; } } .login-submit:hover { filter: brightness(1.12); } .login-submit:active { transform: scale(0.98); } +.login-sso { + display: block; margin-top: 12px; padding: 11px 18px; text-align: center; + color: var(--text-1, #e6ebf2); text-decoration: none; + border: 1px solid var(--border); border-radius: 10px; + font: inherit; font-weight: 500; font-size: 0.95rem; + transition: filter 0.15s, transform 0.1s; +} +.login-sso:hover { filter: brightness(1.35); } +.login-sso:active { transform: scale(0.98); } .login-foot { margin: 18px 0 0; text-align: center; color: var(--text-3, #8b94a5); font-size: 0.8rem; } + +/* ── Fusion ACC-1 (kanbante#28) : cohabitation avec la barre commune ── + La barre d'apps (60 px, en flux en haut du body) repousse la barre + latérale fixe sous elle ; le reste du cadre suit le flux. */ +body.has-draco-bar .sidebar { top: 60px; } diff --git a/static/js/draco-barre.js b/static/js/draco-barre.js new file mode 100644 index 0000000..b39ba50 --- /dev/null +++ b/static/js/draco-barre.js @@ -0,0 +1,56 @@ +/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2) + * + * Aucun gestionnaire inline : un seul écouteur délégué sur le document + * (patron validé par le prototype — les onclick du HTML généré sont + * proscrits dans tout le programme). + * + * Configuration attendue avant le chargement : + * window.DRACO_BAR_CONFIG = { + * current: "tiktak", // app courante + * domain: "pro", // domaine actif + * counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 }, + * onAction: function (act, el) { … } // l'app relaie les actions + * } // qu'elle ne connaît pas + * Actions émises (data-act) : rail, palette, domaine (data-dom), + * alertes, spark, menu, rappel-entete. L'app intégratrice décide de + * chacune (ouvrir la palette, le panneau Spark, changer de domaine…). + */ +(function () { + "use strict"; + var cfg = window.DRACO_BAR_CONFIG || {}; + + function setCounts(counts) { + Object.keys(counts || {}).forEach(function (k) { + var b = document.querySelector('.draco-badge[data-badge="' + k + '"]'); + if (!b) return; + b.textContent = counts[k]; + b.hidden = !(counts[k] > 0); + }); + } + function setCurrent(app) { + document.querySelectorAll(".draco-apptab").forEach(function (t) { + t.dataset.active = t.dataset.app === app ? "true" : "false"; + }); + } + function setDomain(dom) { + document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) { + b.dataset.on = b.dataset.dom === dom ? "true" : "false"; + }); + } + + document.addEventListener("click", function (ev) { + var el = ev.target.closest("[data-act]"); + if (!el) return; + var act = el.dataset.act; + if (act === "domaine") setDomain(el.dataset.dom); + if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev); + else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } })); + }); + + /* API publique : l'app met les compteurs à jour sans recharger. */ + window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain }; + + if (cfg.current) setCurrent(cfg.current); + if (cfg.domain) setDomain(cfg.domain); + if (cfg.counts) setCounts(cfg.counts); +})(); diff --git a/tests/test_draco_barre.py b/tests/test_draco_barre.py new file mode 100644 index 0000000..61a6c8a --- /dev/null +++ b/tests/test_draco_barre.py @@ -0,0 +1,39 @@ +"""Fusion ACC-1 — barre d'apps commune (carte kanbante#28). + +Le snippet figé de dracodev-commons est vendangé (CSS/JS statiques + +partiel _draco_bar.html) et inclus par le cadre partagé _header.html : +toutes les pages applicatives l'affichent avec Kanbante comme app +courante ; l'écran de connexion, autonome, ne l'affiche pas. +""" + + +def test_barre_presente_sur_le_dashboard(client): + r = client.get("/") + assert r.status_code == 200 + assert 'class="draco-bar"' in r.text + assert 'data-current="kanbante"' in r.text + assert "draco-barre.css" in r.text + assert "draco-barre.js" in r.text + assert 'window.DRACO_BAR_CONFIG' in r.text + # Les quatre applications de la barre, aux URL publiques. + for url in ("https://og.dracodev.net/", "https://flowdeck.dracodev.net/", + "https://tiktak.dracodev.net/", "https://kanbante.dracodev.net/"): + assert url in r.text + + +def test_statiques_de_la_barre_servis(client): + assert client.get("/static/css/draco-barre.css").status_code == 200 + assert client.get("/static/js/draco-barre.js").status_code == 200 + + +def test_pas_de_barre_sur_le_login(monkeypatch, fake_gitea): + from fastapi.testclient import TestClient + + import app.main as main + + monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") + monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") + with TestClient(main.app) as client: + r = client.get("/login") + assert r.status_code == 200 + assert "draco-bar" not in r.text diff --git a/tests/test_draco_sso.py b/tests/test_draco_sso.py new file mode 100644 index 0000000..1b41f33 --- /dev/null +++ b/tests/test_draco_sso.py @@ -0,0 +1,103 @@ +"""Fusion ACC-3.2 — pont SSO draco_session (carte kanbante#27). + +Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement +(secret partagé, HS256) et converti en session Kanbante native par +GET /auth/draco. Kanbante est mono-utilisateur : un jeton valide +ouvre la session de l'opérateur configuré. Sans +DRACO_SESSION_SECRET, le pont renvoie simplement au login. +""" + +import base64 +import hashlib +import hmac +import json +import time + +import pytest +from fastapi.testclient import TestClient + +import app.main as main +from app import draco + +SECRET = "secret-partage-de-test" + + +def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test"): + def b64(d: bytes) -> str: + return base64.urlsafe_b64encode(d).rstrip(b"=").decode() + + header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + payload = b64(json.dumps({ + "sub": sub, "name": "Bruno", "iat": int(time.time()), + "exp": exp if exp is not None else int(time.time()) + 3600, + "jti": jti, + }).encode()) + sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(), + hashlib.sha256).digest() + return f"{header}.{payload}.{b64(sig)}" + + +@pytest.fixture() +def client_sso(monkeypatch, fake_gitea): + monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") + monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") + monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET) + monkeypatch.setattr(draco, "_refresh_revocations", lambda: None) + draco._revoked_cache.clear() + with TestClient(main.app) as client: + yield client + draco._revoked_cache.clear() + + +def test_verify_token(monkeypatch): + monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET) + assert draco.verify_token(_jeton())["sub"] == "admin-1" + assert draco.verify_token(_jeton(secret="mauvais")) is None + assert draco.verify_token(_jeton(exp=int(time.time()) - 5)) is None + assert draco.verify_token("pas.un.jeton") is None + + +def test_pont_sans_cookie_vers_login(client_sso): + r = client_sso.get("/auth/draco", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/login" + + +def test_pont_cookie_valide_ouvre_la_session(client_sso): + client_sso.cookies.set("draco_session", _jeton()) + r = client_sso.get("/auth/draco", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/" + assert "kanbante_session" in r.headers.get("set-cookie", "") + # La session native créée par le pont donne accès à l'app. + assert client_sso.get("/").status_code == 200 + + +def test_pont_cookie_falsifie_vers_login(client_sso): + client_sso.cookies.set("draco_session", _jeton(secret="mauvais")) + r = client_sso.get("/auth/draco", follow_redirects=False) + assert r.headers["location"] == "/login" + + +def test_pont_jeton_revoque_refuse(client_sso): + draco._revoked_cache["jti-revoque"] = int(time.time()) + 3600 + client_sso.cookies.set("draco_session", _jeton(jti="jti-revoque")) + r = client_sso.get("/auth/draco", follow_redirects=False) + assert r.headers["location"] == "/login" + + +def test_pont_inerte_sans_secret(monkeypatch, fake_gitea): + monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno") + monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse") + monkeypatch.delenv("DRACO_SESSION_SECRET", raising=False) + with TestClient(main.app) as client: + client.cookies.set("draco_session", _jeton()) + r = client.get("/auth/draco", follow_redirects=False) + assert r.headers["location"] == "/login" + + +def test_bouton_sso_sur_la_page_login(client_sso): + r = client_sso.get("/login") + assert r.status_code == 200 + assert "og.dracodev.net/sso/authorize" in r.text + assert "kanbante.dracodev.net%2Fauth%2Fdraco" in r.text