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). #}
+
+
+
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