From cb80b08f7728b732fe168f9e0ddf3acd5c9df764 Mon Sep 17 00:00:00 2001 From: Spark Date: Tue, 20 Oct 2026 23:33:52 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20=C3=A9mettrice=20SSO=20draco=5Fsession?= =?UTF-8?q?=20=E2=80=94=20cookie=20partag=C3=A9=20.dracodev.net,=20renouve?= =?UTF-8?q?llement=20et=20r=C3=A9vocation=20(#216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 18 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/auth/router.py | 19 ++ backend/draco_session.py | 267 ++++++++++++++++++ backend/main.py | 8 + backend/openapi_docs.py | 2 + backend/routers/sso.py | 44 +++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 25 +- .../fusion-acc31-sso-draco-session-216.md | 67 +++++ frontend/js/auth.js | 19 ++ package.json | 2 +- tests/test_draco_session.py | 199 +++++++++++++ 17 files changed, 677 insertions(+), 13 deletions(-) create mode 100644 backend/draco_session.py create mode 100644 backend/routers/sso.py create mode 100644 docs/features/fusion-acc31-sso-draco-session-216.md create mode 100644 tests/test_draco_session.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e8bb5c..9891d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.70.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.71.0**. --- @@ -14,6 +14,22 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.71.0] — 2026-10-20 + +### Ajouté + +- **Fusion ACC-3.1 (#216) — émettrice SSO `draco_session`** : ObsiGate + émet le cookie de session partagé de l'écosystème dracodev (JWS + HS256, claims minimaux, 12 h, domaine `.dracodev.net`) à chaque + connexion, le renouvelle de façon glissante (middleware dédié), le + révoque à la déconnexion (liste des `jti` exposée aux autres apps + sous clé machine) et offre `GET /sso/authorize` avec `return_to` + en liste blanche anti open-redirect. Inerte tant que + `DRACO_SESSION_SECRET` n'est pas configuré. Fiche : + `docs/features/fusion-acc31-sso-draco-session-216.md`. + +--- + ## [2.70.1] — 2026-10-20 ### Corrigé diff --git a/README.fr.md b/README.fr.md index db3b451..53e4366 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.70.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.71.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.70.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.71.0). --- -*Projet : ObsiGate | Version : 2.70.1 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.71.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 8c97ed1..ced0e50 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.70.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.71.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.70.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.71.0). --- -*Project: ObsiGate | Version: 2.70.1 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.71.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index b72241c..6ab6ec3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.70.1 +2.71.0 diff --git a/backend/auth/router.py b/backend/auth/router.py index 9abde0a..77220cf 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -11,6 +11,7 @@ import re from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from pydantic import BaseModel, validator +from backend import draco_session from backend.ratelimit import is_account_rate_limited, is_rate_limited from backend.ratelimit import record_account_failure as rl_record_account_failure from backend.ratelimit import record_account_success as rl_record_account_success @@ -280,6 +281,11 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon secure=secure, path="/", ) + # Fusion ACC-3.1 (#216) : ObsiGate est l'émettrice SSO — chaque + # émission de jetons natifs s'accompagne du cookie partagé + # draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas + # configuré). + draco_session.set_cookie(response, user, secure) return { "access_token": access_token, # OAuth2 token_type, pas un mot de passe (B105) : @@ -396,6 +402,19 @@ async def logout( except Exception: pass + # Fusion ACC-3.1 (#216) : déconnexion globale — révoquer le jti du + # cookie partagé et effacer le cookie sur le domaine parent. + draco_tok = request.cookies.get(draco_session.COOKIE_NAME) + if draco_tok: + draco_claims = draco_session.decode_claims(draco_tok) + if draco_claims and draco_claims.get("jti"): + try: + draco_session.revoke(str(draco_claims["jti"]), + int(draco_claims.get("exp", 0))) + except Exception: + pass + draco_session.clear_cookie(response) + response.delete_cookie("refresh_token", path="/api/auth/refresh") response.delete_cookie("access_token", path="/") response.delete_cookie("access_token", path="/api") # just in case diff --git a/backend/draco_session.py b/backend/draco_session.py new file mode 100644 index 0000000..788d30f --- /dev/null +++ b/backend/draco_session.py @@ -0,0 +1,267 @@ +# backend/draco_session.py +# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2). +# +# ObsiGate est l'unique émettrice du cookie de session partagé +# ``draco_session`` (Domain=.dracodev.net) que les autres applications +# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement +# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 — +# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti), +# aucun rôle dans le jeton, renouvellement glissant par l'émettrice, +# révocation par jti exposée aux autres apps. +# +# Configuration (environnement) : +# DRACO_SESSION_SECRET secret de signature partagé (obligatoire +# pour activer la fonction ; sans lui, +# aucun cookie n'est émis et rien ne change) +# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification +# pendant la fenêtre de rotation (24 h) +# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ; +# vide = cookie lié à l'hôte, utile en dev) +# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la +# liste de révocation (GET /api/sso/revocations) + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +import uuid +from pathlib import Path +from urllib.parse import urlparse + +from jose import JWTError, jwt + +logger = logging.getLogger("obsigate.draco_session") + +COOKIE_NAME = "draco_session" +ALGORITHM = "HS256" +TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2) +RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h +REVOKED_FILE = Path("data/draco_revoked.json") +ALLOWED_RETURN_SUFFIX = "dracodev.net" + +_lock = threading.Lock() +_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement) + + +def _secret() -> str: + return os.environ.get("DRACO_SESSION_SECRET", "") + + +def _secret_prev() -> str: + return os.environ.get("DRACO_SESSION_SECRET_PREV", "") + + +def _cookie_domain() -> str: + return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net") + + +def enabled() -> bool: + """La fonction est active seulement si le secret partagé est configuré.""" + return bool(_secret()) + + +# ── Révocation (jti) ──────────────────────────────────────────────────── + +def _load_revoked() -> dict[str, int]: + global _revoked + if _revoked is None: + try: + data = json.loads(REVOKED_FILE.read_text(encoding="utf-8")) + _revoked = {str(k): int(v) for k, v in data.items()} \ + if isinstance(data, dict) else {} + except (OSError, ValueError): + _revoked = {} + return _revoked + + +def _save_revoked() -> None: + REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = REVOKED_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8") + tmp.replace(REVOKED_FILE) + + +def revoke(jti: str, exp: int) -> None: + """Révoque un jeton par son jti (déconnexion globale).""" + with _lock: + store = _load_revoked() + store[jti] = int(exp) + now = int(time.time()) + for k in [k for k, v in store.items() if v <= now]: + del store[k] # purge des expirés + _save_revoked() + + +def is_revoked(jti: str) -> bool: + with _lock: + return jti in _load_revoked() + + +def revoked_list() -> list[dict]: + """Liste des révocations actives (pour les autres apps, cache 60 s).""" + with _lock: + now = int(time.time()) + return [{"jti": jti, "exp": exp} + for jti, exp in _load_revoked().items() if exp > now] + + +# ── Émission / vérification ───────────────────────────────────────────── + +def issue_token(user: dict) -> tuple[str, str, int]: + """Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp).""" + now = int(time.time()) + exp = now + TOKEN_TTL_SECONDS + jti = uuid.uuid4().hex + claims = { + "sub": str(user.get("id") or user.get("username") or ""), + "name": str(user.get("display_name") or user.get("username") or ""), + "iat": now, + "exp": exp, + "jti": jti, + } + return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp + + +def decode_claims(token: str) -> dict | None: + """Vérifie signature et expiration (pas la révocation).""" + if not token or not enabled(): + return None + for secret in (_secret(), _secret_prev()): + if not secret: + continue + try: + return jwt.decode(token, secret, algorithms=[ALGORITHM]) + except JWTError: + continue + return None + + +def verify_token(token: str) -> dict | None: + """decode_claims + contrôle de révocation du jti.""" + claims = decode_claims(token) + if claims is None: + return None + if claims.get("jti") and is_revoked(str(claims["jti"])): + return None + return claims + + +def needs_renewal(claims: dict) -> bool: + """Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h).""" + try: + return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS + except (KeyError, TypeError, ValueError): + return True + + +def set_cookie(response, user: dict, secure: bool) -> None: + """Pose le cookie draco_session sur une réponse (émission initiale).""" + if not enabled(): + return + token, _jti, _exp = issue_token(user) + domain = _cookie_domain() + extra = {"domain": domain} if domain else {} + response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS, + httponly=True, samesite="lax", secure=secure, + path="/", **extra) + + +def clear_cookie(response) -> None: + domain = _cookie_domain() + extra = {"domain": domain} if domain else {} + response.delete_cookie(COOKIE_NAME, path="/", **extra) + + +# ── Renouvellement glissant (middleware) ──────────────────────────────── + +class DracoRenewalMiddleware: + """Réémet le cookie quand l'utilisateur est actif chez l'émettrice. + + Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate + streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en + tampon). On enveloppe l'envoi de la réponse : si la requête porte + un cookie d'accès natif valide et que le cookie draco est absent, + invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute + un Set-Cookie frais aux en-têtes de la réponse. La révocation + ciblée d'un jti draco est ainsi « guérie » par la prochaine + activité authentifiée locale — la déconnexion globale, elle, + révoque aussi les jetons natifs, donc plus rien n'est réémis. + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope.get("type") != "http" or not enabled(): + await self.app(scope, receive, send) + return + cookie_header = "" + for name, value in scope.get("headers") or []: + if name == b"cookie": + cookie_header = value.decode("latin-1") + break + cookies = {} + for part in cookie_header.split(";"): + if "=" in part: + k, v = part.strip().split("=", 1) + cookies[k] = v + user = self._user_from_native(cookies.get("access_token", "")) + if user is None: + await self.app(scope, receive, send) + return + claims = verify_token(cookies.get(COOKIE_NAME, "")) \ + if cookies.get(COOKIE_NAME) else None + if claims is not None and not needs_renewal(claims): + await self.app(scope, receive, send) + return + + token, _jti, _exp = issue_token(user) + domain = _cookie_domain() + cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; " + f"Path=/; HttpOnly; SameSite=lax") + if domain: + cookie += f"; Domain={domain}" + if scope.get("scheme") == "https": + cookie += "; Secure" + + async def send_with_cookie(message): + if message.get("type") == "http.response.start": + headers = list(message.get("headers") or []) + headers.append((b"set-cookie", cookie.encode("latin-1"))) + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_with_cookie) + + @staticmethod + def _user_from_native(access_token: str): + """Utilisateur local prouvé par le cookie d'accès natif, ou None.""" + if not access_token: + return None + try: + from backend.auth.jwt_handler import decode_token + from backend.auth.user_store import get_user + payload = decode_token(access_token) + if not payload or payload.get("type") != "access": + return None + return get_user(str(payload.get("sub", ""))) + except Exception: # le renouvellement ne casse jamais une requête + return None + + +# ── return_to (anti open-redirect) ────────────────────────────────────── + +def validate_return_to(url: str) -> str | None: + """Accepte uniquement https vers dracodev.net ou un sous-domaine.""" + if not url: + return None + parsed = urlparse(url) + host = (parsed.hostname or "").lower() + if parsed.scheme != "https" or not host: + return None + if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX): + return url + return None diff --git a/backend/main.py b/backend/main.py index 790ca3e..782329a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -493,6 +493,12 @@ from backend.auth.scope import TokenScopeMiddleware app.add_middleware(TokenScopeMiddleware) +# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO +# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini). +from backend.draco_session import DracoRenewalMiddleware + +app.add_middleware(DracoRenewalMiddleware) + # Explicit same-origin CORS policy (#87 T8 — finit BUG-034). # `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`, # donc toute lecture cross-origin est refusée (défense explicite, plus @@ -534,6 +540,7 @@ from backend.routers.realtime import router as realtime_router from backend.routers.scheduler import router as scheduler_router from backend.routers.search import router as search_router from backend.routers.sharing import router as sharing_router +from backend.routers.sso import router as sso_router from backend.routers.vaults import router as vaults_router from backend.routers.webhooks import router as webhooks_router from backend.skills_routes import router as skills_router @@ -545,6 +552,7 @@ app.include_router(skills_router) app.include_router(health_router) # ROADMAP #85 T1 — System / health app.include_router(history_router) # ROADMAP #85 T8 — History app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3) +app.include_router(sso_router) # Fusion #216 — Émettrice SSO draco_session (ACC-3.1) app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups diff --git a/backend/openapi_docs.py b/backend/openapi_docs.py index 365e92e..f454c09 100644 --- a/backend/openapi_docs.py +++ b/backend/openapi_docs.py @@ -29,6 +29,7 @@ TAGS_METADATA: list[dict[str, str]] = [ {"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."}, {"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."}, {"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."}, + {"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."}, {"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."}, {"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."}, {"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."}, @@ -116,6 +117,7 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [ (re.compile(r"^/api/recent"), "Bookmarks"), (re.compile(r"^/api/bookmarks"), "Bookmarks"), (re.compile(r"^/api/refs"), "ExternalRefs"), + (re.compile(r"^/api/sso"), "SSO"), (re.compile(r"^/api/saved-searches"), "Bookmarks"), (re.compile(r"^/api/vaults"), "Vaults"), (re.compile(r"^/api/vault/"), "Vaults"), diff --git a/backend/routers/sso.py b/backend/routers/sso.py new file mode 100644 index 0000000..7e9cfc1 --- /dev/null +++ b/backend/routers/sso.py @@ -0,0 +1,44 @@ +# backend/routers/sso.py +# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice. +# +# GET /sso/authorize?return_to= : point d'entrée des autres +# applications — valide le return_to (liste blanche *.dracodev.net, +# anti open-redirect) puis redirige vers la page de connexion +# d'ObsiGate en conservant le paramètre ; après connexion, le +# frontend repart vers l'application demanderesse (le cookie +# draco_session posé à la connexion couvre tout le domaine parent). +# +# GET /api/sso/revocations : liste des jti révoqués, réservée aux +# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête +# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2). + +from __future__ import annotations + +import os +from urllib.parse import quote + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse + +from backend import draco_session + +router = APIRouter(tags=["SSO"]) + + +@router.get("/sso/authorize") +async def sso_authorize(return_to: str = ""): + target = draco_session.validate_return_to(return_to) + if target: + return RedirectResponse( + url=f"/?return_to={quote(target, safe='')}", status_code=302) + return RedirectResponse(url="/", status_code=302) + + +@router.get("/api/sso/revocations") +async def sso_revocations(request: Request): + machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "") + if not machine_key: + raise HTTPException(404, "Non configuré") + if request.headers.get("X-Draco-Machine-Key") != machine_key: + raise HTTPException(401, "Clé machine invalide") + return {"revoked": draco_session.revoked_list()} diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index acca648..1d5c5ba 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.70.1" +version = "2.71.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 8a9dba7..60180e8 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.70.1" +version = "2.71.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index a32f15c..e9d611f 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.70.1", + "version": "2.71.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cc64d7f..a6fe804 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.70.1 | **Dernière mise à jour :** 2026-10-20 +> **Version :** 2.71.0 | **Dernière mise à jour :** 2026-10-20 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -56,6 +56,29 @@ bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak » - [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py` +### 216. Fusion ACC-3.1 — émettrice SSO `draco_session` + +- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.71.0) +- **Fiche :** [features/fusion-acc31-sso-draco-session-216.md](./features/fusion-acc31-sso-draco-session-216.md) +- **Description :** couche 2 (identité) du programme **Fusion** — décision C2 + de Bruno : cookie de session partagé sous `.dracodev.net` (architecture + v2.0 §5.2, option 2a). ObsiGate est l'unique émettrice ; les autres apps + valideront localement (ACC-3.2). Issue Gitea ObsiGate #8 ; épopée + Projets/fusion-trois-apps#7. +- **Sous-tâches :** + - [x] Module `backend/draco_session.py` : JWS HS256, claims minimaux + (sub, name, iat, exp 12 h, jti — aucun rôle), rotation par + `DRACO_SESSION_SECRET_PREV`, inerte sans `DRACO_SESSION_SECRET` + - [x] Émission du cookie à chaque `_issue_tokens` (mot de passe ou MFA) + - [x] Renouvellement glissant (middleware ASGI `DracoRenewalMiddleware`, + fenêtre 2 h, conditionné au cookie d'accès natif valide) + - [x] Déconnexion globale : révocation du jti (`data/draco_revoked.json`) + + effacement du cookie ; `GET /api/sso/revocations` sous clé machine + - [x] `GET /sso/authorize` : `return_to` en liste blanche + `*.dracodev.net` (anti open-redirect), retour post-connexion + re-validé côté frontend (`auth.js`) + - [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22) + ### 196. Partage dirigé entre utilisateurs (document → utilisateur) - **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10 diff --git a/docs/features/fusion-acc31-sso-draco-session-216.md b/docs/features/fusion-acc31-sso-draco-session-216.md new file mode 100644 index 0000000..2a2e8ba --- /dev/null +++ b/docs/features/fusion-acc31-sso-draco-session-216.md @@ -0,0 +1,67 @@ +# #216 — Émettrice SSO `draco_session` (Fusion ACC-3.1) + +Statut : ✅ livré le 2026-10-21 (2.71.0) + +## Contexte + +Programme Fusion (accès centralisé ObsiGate / Flowdeck / TikTak / +Kanbante), couche 2 — identité. Décision **C2** de Bruno (2026-10-20) : +cookie de session partagé `draco_session` sous `.dracodev.net` +(architecture v2.0 §5.2, option 2a). ObsiGate est l'**unique +émettrice** ; les autres applications valideront le jeton localement +(ACC-3.2, leurs tickets respectifs) et redirigeront leur connexion +vers `GET /sso/authorize`. + +## Ce qui est livré + +- **`backend/draco_session.py`** — émission et vérification du JWS + (HS256, jose) : claims minimaux `sub` (id utilisateur ObsiGate), + `name`, `iat`, `exp` (**12 h**), `jti` ; **aucun rôle** dans le + jeton. Rotation : `DRACO_SESSION_SECRET_PREV` accepté en + vérification pendant la fenêtre de bascule. +- **Émission à la connexion** : `_issue_tokens` (auth/router.py) pose + le cookie à chaque émission de jetons natifs (mot de passe ou MFA) — + attributs `Domain=.dracodev.net; Path=/; HttpOnly; Secure (si la + requête est en https, via `is_secure_cookies`); SameSite=Lax`. +- **Renouvellement glissant** : `DracoRenewalMiddleware` (ASGI pur, + aucun tamponnage des flux SSE/WS) — si la requête porte un cookie + d'accès natif valide et que le cookie draco est absent, invalide ou + à moins de 2 h de son expiration, un cookie frais est ajouté à la + réponse. +- **Déconnexion globale** : le logout révoque le `jti` draco + (en plus des jetons natifs) et efface le cookie du domaine parent. + Révocations persistées dans `data/draco_revoked.json` + (jti → exp, purge des expirés), exposées aux autres apps par + **`GET /api/sso/revocations`** protégé par la clé machine + `DRACO_SSO_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, 404 si la + clé n'est pas configurée) — cache 60 s côté consommatrices. +- **`GET /sso/authorize?return_to=`** : valide le `return_to` + (https + `dracodev.net` ou sous-domaine, liste blanche stricte + anti open-redirect) puis redirige vers la connexion ObsiGate ; + après connexion, `frontend/js/auth.js` repart vers l'application + demanderesse (re-validation du domaine côté client). +- **Inerte par défaut** : sans `DRACO_SESSION_SECRET`, aucun cookie + n'est émis et le comportement d'ObsiGate est inchangé. + +## Configuration (déploiement) + +| Variable | Rôle | +|---|---| +| `DRACO_SESSION_SECRET` | Secret partagé de signature — **le même** devra être configuré dans Flowdeck et TikTak pour leurs validateurs ACC-3.2. À générer fort (ex. 64 hex) et à ne jamais committer. | +| `DRACO_SESSION_SECRET_PREV` | Ancien secret pendant une rotation (24 h). | +| `DRACO_SESSION_COOKIE_DOMAIN` | Domaine du cookie (défaut `.dracodev.net` ; vide = hôte seul, dev local). | +| `DRACO_SSO_MACHINE_KEY` | Clé machine de lecture des révocations, à partager avec les apps consommatrices. | + +⚠️ **Déploiement** : la fonction ne s'active qu'au redéploiement +d'ObsiGate ≥ 2.71.0 **avec** `DRACO_SESSION_SECRET` posé dans +l'environnement du conteneur. Le SSO complet exige ensuite les +validateurs ACC-3.2 (ObsiGate #9, Flowdeck #21, TikTak #213). + +## Tests + +`tests/test_draco_session.py` — 22 tests : claims minimaux, durée de +vie, falsification, expiration, rotation, révocation, fenêtre de +renouvellement, liste blanche `return_to` (8 cas), cookie posé à la +connexion (attributs), inactivité sans secret, révocation au logout, +renouvellement glissant via middleware, endpoint de révocations +(404/401/200). diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 085e3b7..21e9072 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -121,6 +121,25 @@ const AuthManager = { if (tokenData.user) { sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user)); } + this._honorReturnTo(); + }, + + // Fusion ACC-3.1 (#216) : après connexion chez l'émettrice SSO, + // repartir vers l'application demanderesse. Le return_to est + // re-validé ici (https + domaine dracodev.net, jamais l'hôte + // courant) en plus de la validation serveur de /sso/authorize. + _honorReturnTo() { + try { + const rt = new URLSearchParams(window.location.search).get("return_to"); + if (!rt) return; + const u = new URL(rt); + const okHost = u.hostname === "dracodev.net" + || u.hostname.endsWith(".dracodev.net"); + if (u.protocol === "https:" && okHost + && u.hostname !== window.location.hostname) { + window.location.replace(rt); + } + } catch (e) { /* return_to illisible : on reste sur place */ } }, getToken() { diff --git a/package.json b/package.json index ef5e7a7..6195c3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.70.1", + "version": "2.71.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_draco_session.py b/tests/test_draco_session.py new file mode 100644 index 0000000..79047f3 --- /dev/null +++ b/tests/test_draco_session.py @@ -0,0 +1,199 @@ +# tests/test_draco_session.py +# Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la +# connexion, vérification (signature, expiration, révocation, rotation), +# renouvellement glissant, return_to en liste blanche, révocations. + +import time + +import pytest +from jose import jwt as jose_jwt + +from backend import draco_session + +USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"} + + +@pytest.fixture(autouse=True) +def _draco_env(monkeypatch, tmp_path): + monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test") + monkeypatch.setattr(draco_session, "REVOKED_FILE", + tmp_path / "draco_revoked.json") + draco_session._revoked = None + yield + draco_session._revoked = None + + +# ── Jeton : émission / vérification ───────────────────────────────────── + +def test_roundtrip_claims_minimaux(): + token, jti, exp = draco_session.issue_token(USER) + claims = draco_session.verify_token(token) + assert claims is not None + assert claims["sub"] == "admin-1" + assert claims["name"] == "Admin" + assert claims["jti"] == jti + assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2 + assert "role" not in claims # aucun rôle dans le jeton + + +def test_jeton_falsifie_rejete(): + token, _j, _e = draco_session.issue_token(USER) + assert draco_session.verify_token(token + "x") is None + autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret", + algorithm="HS256") + assert draco_session.verify_token(autre) is None + + +def test_jeton_expire_rejete(): + now = int(time.time()) + token = jose_jwt.encode( + {"sub": "admin-1", "name": "Admin", "iat": now - 7200, + "exp": now - 3600, "jti": "ancien"}, + "secret-de-test", algorithm="HS256") + assert draco_session.verify_token(token) is None + + +def test_rotation_secret_precedent_accepte(monkeypatch): + token = jose_jwt.encode( + {"sub": "admin-1", "name": "Admin", "iat": int(time.time()), + "exp": int(time.time()) + 3600, "jti": "rotation"}, + "ancien-secret", algorithm="HS256") + assert draco_session.verify_token(token) is None # pas encore configuré + monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret") + assert draco_session.verify_token(token)["jti"] == "rotation" + + +def test_revocation(): + token, jti, exp = draco_session.issue_token(USER) + assert draco_session.verify_token(token) is not None + draco_session.revoke(jti, exp) + assert draco_session.verify_token(token) is None + assert {"jti": jti, "exp": exp} in draco_session.revoked_list() + + +def test_inerte_sans_secret(monkeypatch): + monkeypatch.delenv("DRACO_SESSION_SECRET") + assert draco_session.enabled() is False + assert draco_session.verify_token("peu-importe") is None + + +def test_fenetre_de_renouvellement(): + assert draco_session.needs_renewal({"exp": int(time.time()) + 1800}) + assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600}) + + +# ── return_to : liste blanche ──────────────────────────────────────────── + +@pytest.mark.parametrize("url,ok", [ + ("https://tiktak.dracodev.net/?task=3", True), + ("https://og.dracodev.net/", True), + ("https://dracodev.net/x", True), + ("http://tiktak.dracodev.net/", False), # https seulement + ("https://evil.com/", False), + ("https://tiktak.dracodev.net.evil.com/", False), + ("https://notdracodev.net/", False), + ("", False), +]) +def test_validate_return_to(url, ok): + assert (draco_session.validate_return_to(url) is not None) == ok + + +# ── Intégration : connexion / déconnexion / renouvellement ────────────── + +def _login(client): + r = client.post("/api/auth/login", + json={"username": "admin", "password": "chab30"}) + assert r.status_code == 200, r.text + return r + + +def _draco_cookie(response): + for raw in response.headers.get_list("set-cookie"): + if raw.startswith("draco_session="): + return raw + return None + + +def test_login_pose_le_cookie(admin_client): + r = _login(admin_client) + cookie = _draco_cookie(r) + assert cookie is not None + assert "Domain=.dracodev.net" in cookie + assert "HttpOnly" in cookie and "SameSite=lax" in cookie + token = cookie.split(";")[0].split("=", 1)[1] + assert draco_session.verify_token(token)["sub"] == "admin-1" + + +def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch): + monkeypatch.delenv("DRACO_SESSION_SECRET") + r = _login(admin_client) + assert _draco_cookie(r) is None + + +def _token_depuis_login(client): + # Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte + # de test différent) : on lit le jeton dans l'en-tête set-cookie. + cookie = _draco_cookie(_login(client)) + assert cookie is not None + return cookie.split(";")[0].split("=", 1)[1] + + +def test_logout_revoque_le_jti(admin_client): + token = _token_depuis_login(admin_client) + assert draco_session.verify_token(token) + r = admin_client.post("/api/auth/logout", + headers={"Cookie": f"draco_session={token}"}) + assert r.status_code == 200, r.text + assert draco_session.verify_token(token) is None # révoqué + + +def test_renouvellement_glissant(admin_client): + _login(admin_client) + # Cookie draco presque expiré (dans la fenêtre des 2 h) : + now = int(time.time()) + vieux = jose_jwt.encode( + {"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600, + "exp": now + 600, "jti": "vieux-jti"}, + "secret-de-test", algorithm="HS256") + admin_client.cookies.set("draco_session", vieux) + r = admin_client.get("/api/auth/me") + assert r.status_code == 200, r.text + cookie = _draco_cookie(r) + assert cookie is not None # réémis + nouveau = cookie.split(";")[0].split("=", 1)[1] + assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti" + + +def test_pas_de_renouvellement_si_frais(admin_client): + token = _token_depuis_login(admin_client) + admin_client.cookies.set("draco_session", token) # cookie frais porté + r = admin_client.get("/api/auth/me") + assert r.status_code == 200 + assert _draco_cookie(r) is None # cookie frais : rien + + +def test_sso_authorize_liste_blanche(client): + r = client.get("/sso/authorize", + params={"return_to": "https://tiktak.dracodev.net/?task=3"}, + follow_redirects=False) + assert r.status_code == 302 + assert "return_to=" in r.headers["location"] + r = client.get("/sso/authorize", + params={"return_to": "https://evil.com/phish"}, + follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"] == "/" # refusé, pas de relais + + +def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch): + token, jti, exp = draco_session.issue_token(USER) + draco_session.revoke(jti, exp) + r = admin_client.get("/api/sso/revocations") + assert r.status_code == 404 # clé non configurée + monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine") + r = admin_client.get("/api/sso/revocations") + assert r.status_code == 401 # en-tête absent + r = admin_client.get("/api/sso/revocations", + headers={"X-Draco-Machine-Key": "cle-machine"}) + assert r.status_code == 200 + assert {"jti": jti, "exp": exp} in r.json()["revoked"]