From a4852d38896ae90bb2313f9a2cd079a00d44019d Mon Sep 17 00:00:00 2001
From: Spark
Date: Sun, 8 Nov 2026 04:34:10 +0000
Subject: [PATCH 1/2] =?UTF-8?q?Flowdeck=207.85.7=20=E2=80=94=20coh=C3=A9re?=
=?UTF-8?q?nce=20d'identit=C3=A9=20au=20changement=20de=20compte=20:=20la?=
=?UTF-8?q?=20session=20locale=20s'aligne=20sur=20le=20cookie=20draco=20(m?=
=?UTF-8?q?iddleware=20pages,=20marqueurs=20de=20session=20draco=5Fsub/sec?=
=?UTF-8?q?ours)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 9 ++
VERSION | 2 +-
app/auth/session.py | 48 +++++++-
app/main.py | 8 ++
app/middleware/coherence_draco.py | 110 +++++++++++++++++
app/routers/auth.py | 15 ++-
app/services/draco.py | 24 ++++
tests/test_coherence_identite.py | 190 ++++++++++++++++++++++++++++++
8 files changed, 398 insertions(+), 8 deletions(-)
create mode 100644 app/middleware/coherence_draco.py
create mode 100644 tests/test_coherence_identite.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d23bb30..3718aea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,12 @@
+## v7.85.7 (2026-11-07) — Cohérence d'identité au changement de compte
+
+Cas réel (capture) : connecté « fred » chez ObsiGate, l'utilisateur ouvre Flowdeck et y trouve son propre compte (BC) — la session locale préexistante gouvernait sans reconsulter l'identité centrale, le pont `/auth/draco` ne s'exécutant qu'à l'entrée SSO sans session locale.
+
+- **Nouveau middleware `CoherenceDracoMiddleware`** (`app/middleware/coherence_draco.py`, le plus externe) : sur les **pages** (GET hors `/api/`, `/auth/`, `/static/`, `/s/`, `/f/`, `/scim/`), quand la requête porte une session locale valide ET un cookie `draco_session` **valide** (nouveau helper public `app.services.draco.claims_valides` : signature, expiration, révocation, `sub` présent) dont le `sub` diffère de celui que la session sert, la session locale est **révoquée** (`user_sessions`), le cookie est supprimé de la réponse et le navigateur est renvoyé au pont `/auth/draco?next=…` (JIT si besoin). Base de comparaison : le `draco_sub` inscrit dans le payload signé à la création par le pont — sinon le lien actif du compte (`draco.current_link`). Le sub inscrit neutralise le repli mono-utilisateur de la résolution (en mono-utilisateur, un sub étranger résout vers l'unique compte et ne doit pas détruire sa session en boucle).
+- **Gardes** : aucun cookie ou cookie invalide/expiré/révoqué → rien (une panne de l'émettrice ne déconnecte personne) ; cookie que le pont ne peut pas servir (rôle « aucun » — `RoleAucunRefuse` —, dissociation, compte inactif) ou qui résout vers le même compte → session gardée, pas d'exclusion en boucle ; **compte local sans aucun lien** → l'identité centrale présente gagne (choix documenté ; en mode porte centrale, ce cas n'existe pas hors secours). **API non traversée** : un appel en vol n'est ni interrompu ni rejoué sous une autre identité ; dès la navigation suivante, la page sert l'identité du cookie et la session alignée gouverne l'API.
+- **Marqueurs de session** (`app/auth/session.py`) : `create_session(..., draco_sub=…, secours=…)` ; le pont pose `draco_sub`, la connexion de la porte centrale pose `secours=True` (sessions exemptées du contrôle) ; `refresh_session` préserve les marqueurs ; nouveau `decode_payload` (payload complet, sans touch) sur lequel `decode_session` s'appuie.
+- **Tests** : nouveau `tests/test_coherence_identite.py` (7) — le cas de la capture (session révoquée en base, pont servant ensuite le compte du cookie, identité lue par `/auth/user`), même sub / sans cookie / cookie invalide intacts, secours exemptée, compte sans lien aligné, marqueur inscrit par le pont et alignement au changement de cookie.
+
## v7.85.6 (2026-11-06) — Porte centrale unique (phase 2 du programme « compte partout »), désactivée par défaut
> Branche basée sur la 7.85.5 (phase 1, #146/#144) — PR distincte, à fusionner après elle.
diff --git a/VERSION b/VERSION
index 582007e..0146993 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-7.85.6
+7.85.7
diff --git a/app/auth/session.py b/app/auth/session.py
index 6cb9765..0d53101 100644
--- a/app/auth/session.py
+++ b/app/auth/session.py
@@ -36,16 +36,28 @@ class SessionManager:
"""
@staticmethod
- def create_session(user_data: dict, request=None) -> str:
+ def create_session(user_data: dict, request=None, draco_sub: str | None = None,
+ secours: bool = False) -> str:
"""Create a signed session cookie value.
``request`` is optional — when provided the session is recorded in the
``user_sessions`` table (ip + user agent) and becomes revocable.
+
+ Cohérence d'identité (7.85.7) : ``draco_sub`` inscrit dans le
+ payload signé le sub draco que la session sert (posé par le
+ pont) — c'est la base de comparaison du contrôle de
+ cohérence, insensible au repli mono-utilisateur de la
+ résolution ; ``secours=True`` marque une session de la porte
+ de secours, exemptée de ce contrôle.
"""
payload = {
"user": public_user(user_data),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
+ if draco_sub:
+ payload["draco_sub"] = str(draco_sub)
+ if secours:
+ payload["secours"] = True
user_id = user_data.get("id")
if user_id:
sid = str(uuid4())
@@ -54,17 +66,31 @@ class SessionManager:
return _serializer.dumps(payload)
@staticmethod
- def decode_session(cookie: str) -> dict | None:
- """Decode and validate a session cookie. Returns user data or None."""
+ def decode_payload(cookie: str) -> dict | None:
+ """Decode the full session payload (user, sid, markers), or None.
+
+ Same validity checks as ``decode_session`` (signature,
+ expiration, server-side revocation) but no ``last_seen``
+ touch and no unwrapping — the coherence middleware reads
+ the identity markers from here.
+ """
try:
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
except (BadSignature, SignatureExpired):
return None
-
sid = payload.get("sid") or ""
if sid and not _session_active(sid):
# Revoked or deleted session → treat as logged out.
return None
+ return payload
+
+ @staticmethod
+ def decode_session(cookie: str) -> dict | None:
+ """Decode and validate a session cookie. Returns user data or None."""
+ payload = SessionManager.decode_payload(cookie)
+ if payload is None:
+ return None
+ sid = payload.get("sid") or ""
if sid:
_touch_session(sid)
return payload.get("user")
@@ -103,12 +129,24 @@ class SessionManager:
@staticmethod
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
- """Re-sign a cookie keeping its session id (used after profile edits)."""
+ """Re-sign a cookie keeping its session id (used after profile edits).
+
+ Les marqueurs de cohérence (``draco_sub``, ``secours`` —
+ 7.85.7) de l'ancien payload sont préservés : un rafraîchi
+ de profil ne doit ni perdre l'identité servie ni l'exemption
+ de secours.
+ """
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": public_user(user_data),
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
}
+ if cookie:
+ ancien = SessionManager.decode_payload(cookie)
+ if ancien:
+ for marqueur in ("draco_sub", "secours"):
+ if ancien.get(marqueur):
+ payload[marqueur] = ancien[marqueur]
user_id = user_data.get("id")
if user_id:
if sid is None:
diff --git a/app/main.py b/app/main.py
index 8da03e8..f689f88 100644
--- a/app/main.py
+++ b/app/main.py
@@ -280,6 +280,14 @@ app.add_middleware(
from app.middleware.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
+
+# Cohérence d'identité (7.85.7) : ajoutée en dernier, donc la plus
+# externe — une page dont la session locale contredit le cookie
+# draco valide présenté est renvoyée au pont avant tout service.
+# Le middleware ne traverse que les GET de pages (voir son module).
+from app.middleware.coherence_draco import CoherenceDracoMiddleware
+
+app.add_middleware(CoherenceDracoMiddleware)
app.include_router(auth.router)
app.include_router(sso_router)
app.include_router(dashboard.router)
diff --git a/app/middleware/coherence_draco.py b/app/middleware/coherence_draco.py
new file mode 100644
index 0000000..de3c4f8
--- /dev/null
+++ b/app/middleware/coherence_draco.py
@@ -0,0 +1,110 @@
+"""FlowDeck — cohérence d'identité au changement de compte (7.85.7).
+
+Cas réel : connecté « fred » chez ObsiGate, l'utilisateur ouvre
+Flowdeck et y trouve son propre compte (BC) — la session locale
+préexistante gouvernait sans jamais reconsulter l'identité
+centrale, le pont ``/auth/draco`` ne s'exécutant qu'à l'entrée
+SSO sans session locale.
+
+Ce middleware aligne la session sur l'identité centrale
+**présentée** : quand une requête de page (GET hors ``/api/``,
+``/auth/``, ``/static/``, ``/s/``, ``/f/``, ``/scim/``) porte à la
+fois une session locale valide et un cookie ``draco_session``
+**valide** (``app.services.draco.claims_valides`` : signature,
+expiration, révocation) dont le ``sub`` diffère de celui que la
+session sert, la session locale est révoquée (ligne
+``user_sessions``), le cookie est supprimé de la réponse, et le
+navigateur est renvoyé au pont ``/auth/draco?next=…`` (JIT si
+besoin). Base de comparaison : le ``draco_sub`` inscrit dans le
+payload signé de la session à sa création par le pont — sinon le
+lien actif du compte (``draco.current_link``). Le sub inscrit
+neutralise le repli mono-utilisateur de la résolution : en
+mono-utilisateur, un sub étranger résout vers l'unique compte et
+ne doit pas détruire sa session en boucle.
+
+Gardes :
+
+- aucun cookie draco, ou cookie invalide/expiré/révoqué → rien :
+ une panne ou une absence de l'émettrice ne déconnecte personne ;
+- session de la **porte de secours** (marqueur ``secours`` du
+ payload) → exemptée : elle vit hors circuit central ;
+- cookie que le pont ne peut pas servir (rôle « aucun » au
+ registre — ``user_for_draco`` lève ``RoleAucunRefuse`` —,
+ dissociation, compte inactif) ou qui résout vers le **même**
+ compte → session gardée : la détruire exclurait son titulaire
+ en boucle, le cookie refusé restant présent ;
+- compte local **sans aucun lien** draco → l'identité centrale
+ présente gagne (le pont s'applique ; choix documenté). En mode
+ porte centrale, ce cas n'existe pas hors secours.
+
+Périmètre : les **pages**. L'API n'est pas traversée par ce
+middleware : un appel en vol n'est ni interrompu ni rejoué sous
+une autre identité ; dès la navigation suivante, la page sert
+l'identité du cookie et la session alignée gouverne l'API.
+Après alignement, le ``draco_sub`` de la nouvelle session égale
+celui du cookie : pas de boucle.
+"""
+from __future__ import annotations
+
+import logging
+from urllib.parse import quote
+
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.requests import Request
+from starlette.responses import RedirectResponse
+
+from app.auth.session import SessionManager
+
+logger = logging.getLogger(__name__)
+
+_EXEMPT_PREFIXES = ("/api/", "/auth/", "/static/", "/s/", "/f/", "/scim/")
+
+
+class CoherenceDracoMiddleware(BaseHTTPMiddleware):
+ """Aligne la session locale sur le cookie draco valide présenté."""
+
+ async def dispatch(self, request: Request, call_next):
+ if request.method != "GET":
+ return await call_next(request)
+ path = request.url.path
+ if any(path.startswith(p) for p in _EXEMPT_PREFIXES):
+ return await call_next(request)
+ cookie = request.cookies.get("flowdeck_session", "")
+ if not cookie:
+ return await call_next(request)
+ payload = SessionManager.decode_payload(cookie)
+ if payload is None:
+ return await call_next(request)
+ if payload.get("secours"):
+ return await call_next(request)
+ user = payload.get("user") or {}
+ user_id = user.get("id")
+ if not user_id:
+ return await call_next(request)
+ from app.services import draco
+
+ claims = draco.claims_valides(request)
+ if claims is None:
+ return await call_next(request)
+ sub_cookie = str(claims.get("sub", ""))
+ base = payload.get("draco_sub") or draco.current_link(user_id)
+ if base == sub_cookie:
+ return await call_next(request)
+ # Servabilité : le pont doit pouvoir servir un AUTRE compte,
+ # sinon la session est gardée (pas d'exclusion en boucle).
+ try:
+ candidat = draco.user_for_draco(request)
+ except Exception:
+ candidat = None
+ if candidat is None or candidat.get("id") == user_id:
+ return await call_next(request)
+ sid = payload.get("sid")
+ if sid:
+ SessionManager.revoke_session(sid)
+ cible = path
+ if request.url.query:
+ cible = f"{path}?{request.url.query}"
+ response = RedirectResponse(
+ url=f"/auth/draco?next={quote(cible, safe='')}", status_code=302)
+ response.delete_cookie("flowdeck_session", path="/")
+ return response
diff --git a/app/routers/auth.py b/app/routers/auth.py
index e1e4669..725612b 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -484,7 +484,11 @@ def local_login(request: Request, body: dict = Body(default={})):
{"error": "Porte de secours : identifiants invalides."},
status_code=401,
)
- session = SessionManager.create_session(compte_secours, request)
+ # Session marquée « secours » (7.85.7) : exemptée du
+ # contrôle de cohérence d'identité — elle vit hors circuit
+ # central par construction.
+ session = SessionManager.create_session(
+ compte_secours, request, secours=True)
_log_login(compte_secours["id"], request)
response = JSONResponse({"status": "ok", "user": {
"login": compte_secours["login"],
@@ -701,7 +705,14 @@ de l'instance reste possible par la page de connexion.
)
if user is None:
return RedirectResponse(url="/auth/login?provider=local", status_code=302)
- session = SessionManager.create_session(user, request)
+ # Cohérence d'identité (7.85.7) : la session créée par le pont
+ # inscrit le sub qu'elle sert dans son payload signé — base de
+ # comparaison du contrôle de cohérence, insensible au repli
+ # mono-utilisateur de la résolution.
+ _claims = draco.claims_valides(request)
+ session = SessionManager.create_session(
+ user, request,
+ draco_sub=str(_claims.get("sub", "")) if _claims else None)
_log_login(user["id"], request)
cible = next if next.startswith("/") and not next.startswith("//") else "/workspaces"
response = RedirectResponse(url=cible, status_code=302)
diff --git a/app/services/draco.py b/app/services/draco.py
index 5c8f7e4..94573c6 100644
--- a/app/services/draco.py
+++ b/app/services/draco.py
@@ -149,6 +149,30 @@ def is_revoked(jti: str) -> bool:
return jti in _revoked_cache
+def claims_valides(request) -> dict | None:
+ """Claims du cookie draco de la requête, validité complète, ou None.
+
+ Signature et expiration (``verify_token``), révocation du
+ ``jti`` selon le cache alimenté par l'émettrice, ``sub``
+ présent — la même validité que celle qu'exige
+ ``user_for_draco`` avant de résoudre. Consommé par le contrôle
+ de cohérence d'identité (7.85.7) : un cookie absent, invalide,
+ expiré ou révoqué vaut « aucune identité centrale présentée »
+ et ne doit rien déclencher.
+ """
+ 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 str(claims.get("sub", "")):
+ return None
+ return claims
+
+
# ── Résolution sub → utilisateur local ──────────────────────────────────
def _linked_user(conn, sub: str) -> dict | None:
diff --git a/tests/test_coherence_identite.py b/tests/test_coherence_identite.py
new file mode 100644
index 0000000..a2525c2
--- /dev/null
+++ b/tests/test_coherence_identite.py
@@ -0,0 +1,190 @@
+"""Cohérence d'identité au changement de compte (7.85.7).
+
+Cas réel (capture de Bruno) : connecté « fred » chez ObsiGate, il
+ouvre Flowdeck et y trouve son propre compte — la session locale
+préexistante gouvernait sans reconsulter l'identité centrale, le
+pont ne s'exécutant qu'à l'entrée SSO sans session locale.
+
+Le middleware ``CoherenceDracoMiddleware`` aligne la session sur
+le cookie draco valide présenté : révocation de la session locale
++ renvoi des pages au pont. Gardes couvertes ici : même sub
+(inchangée), aucun cookie / cookie invalide (intacte), session de
+secours marquée (exemptée), compte local sans aucun lien
+(l'identité centrale présente gagne), marqueur ``draco_sub``
+inscrit par le pont dans le payload de session.
+"""
+
+import base64
+import hashlib
+import hmac
+import json
+import time
+
+import pytest
+
+from app.auth.session import SessionManager
+from app.db import get_conn
+from app.password_utils import hash_password
+from app.services import draco
+
+from tests.conftest import anon
+
+SECRET = "coherence-secret"
+SUB_PROPRIO = "coh-sub-proprio"
+SUB_FRED = "coh-sub-fred"
+MDP = "mot-de-passe-coh"
+
+
+def _jeton(sub, secret=SECRET, exp=None):
+ 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": "Testeur", "iat": int(time.time()),
+ "exp": exp if exp is not None else int(time.time()) + 3600,
+ "jti": f"jti-{sub}",
+ }).encode())
+ sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
+ hashlib.sha256).digest()
+ return f"{header}.{payload}.{b64(sig)}"
+
+
+@pytest.fixture(autouse=True)
+def _env(monkeypatch):
+ monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
+ monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
+ draco._revoked_cache.clear()
+ yield
+ draco._revoked_cache.clear()
+
+
+def _creer(login, sub=None):
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO users (login, email, password_hash, full_name, is_active) "
+ "VALUES (?, ?, ?, ?, 1)",
+ (login, f"{login}@ex.ca", hash_password(MDP), login.title()))
+ uid = cur.lastrowid
+ if sub:
+ conn.execute(
+ "INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
+ (sub, uid))
+ conn.commit()
+ return uid
+
+
+def _connecter(client, login):
+ r = client.post("/auth/local-login",
+ json={"email": login, "password": MDP})
+ assert r.status_code == 200, r.text
+
+
+@pytest.fixture()
+def deux_comptes(client):
+ proprio_id = _creer("cohproprio", SUB_PROPRIO)
+ fred_id = _creer("cohfred", SUB_FRED)
+ return proprio_id, fred_id
+
+
+# ── (iii) le cas de Bruno : session d'un autre compte ────────────
+
+def test_session_autre_compte_alignee_sur_le_cookie(client, deux_comptes):
+ proprio_id, _ = deux_comptes
+ anon(client)
+ _connecter(client, "cohproprio")
+ client.cookies.set("draco_session", _jeton(SUB_FRED))
+ r = client.get("/workspaces", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers["location"].startswith("/auth/draco")
+ assert "Max-Age=0" in r.headers.get("set-cookie", "")
+ # La session locale du proprio est révoquée côté serveur.
+ with get_conn() as conn:
+ rows = conn.execute(
+ "SELECT revoked FROM user_sessions WHERE user_id = ?",
+ (proprio_id,)).fetchall()
+ assert rows and all(row["revoked"] == 1 for row in rows)
+ # Le pont sert ensuite le compte du cookie.
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers["location"] == "/workspaces"
+ me = client.get("/auth/user")
+ assert me.json()["authenticated"] is True
+ assert me.json()["user"]["login"] == "cohfred"
+
+
+# ── (ii) même sub, (iv) pas de cookie, cookie invalide ───────────
+
+def test_meme_sub_session_inchangee(client, deux_comptes):
+ anon(client)
+ _connecter(client, "cohproprio")
+ client.cookies.set("draco_session", _jeton(SUB_PROPRIO))
+ r = client.get("/workspaces", follow_redirects=False)
+ assert r.status_code == 200
+ me = client.get("/auth/user")
+ assert me.json()["user"]["login"] == "cohproprio"
+
+
+def test_sans_cookie_session_intacte(client, deux_comptes):
+ anon(client)
+ _connecter(client, "cohproprio")
+ r = client.get("/workspaces", follow_redirects=False)
+ assert r.status_code == 200
+
+
+def test_cookie_invalide_session_intacte(client, deux_comptes):
+ anon(client)
+ _connecter(client, "cohproprio")
+ client.cookies.set(
+ "draco_session", _jeton(SUB_FRED, secret="mauvais-secret"))
+ r = client.get("/workspaces", follow_redirects=False)
+ assert r.status_code == 200
+ me = client.get("/auth/user")
+ assert me.json()["user"]["login"] == "cohproprio"
+
+
+# ── (v) session de secours exemptée ──────────────────────────────
+
+def test_session_secours_exemptee(client, deux_comptes):
+ proprio_id, _ = deux_comptes
+ anon(client)
+ session = SessionManager.create_session(
+ {"id": proprio_id, "login": "cohproprio",
+ "full_name": "Cohproprio", "is_admin": 0},
+ None, secours=True)
+ client.cookies.set("flowdeck_session", session)
+ client.cookies.set("draco_session", _jeton(SUB_FRED))
+ r = client.get("/workspaces", follow_redirects=False)
+ assert r.status_code == 200
+
+
+# ── Garde 4 : compte sans lien, l'identité centrale gagne ────────
+
+def test_compte_sans_lien_aligne(client):
+ _creer("cohproprio") # aucun lien draco
+ _creer("cohfred", SUB_FRED)
+ anon(client)
+ _connecter(client, "cohproprio")
+ client.cookies.set("draco_session", _jeton(SUB_FRED))
+ r = client.get("/workspaces", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers["location"].startswith("/auth/draco")
+
+
+# ── Marqueur du pont ─────────────────────────────────────────────
+
+def test_pont_inscrit_le_sub_servi_dans_la_session(client, deux_comptes):
+ anon(client)
+ client.cookies.set("draco_session", _jeton(SUB_FRED))
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.status_code == 302
+ brut = client.cookies.get("flowdeck_session")
+ payload = SessionManager.decode_payload(brut)
+ assert payload is not None
+ assert payload.get("draco_sub") == SUB_FRED
+ # Changement de compte central : le cookie du proprio aligne
+ # la session (base = sub inscrit, pas le lien courant).
+ client.cookies.set("draco_session", _jeton(SUB_PROPRIO))
+ r = client.get("/workspaces", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers["location"].startswith("/auth/draco")
--
2.54.0
From f8b228396d8f8c5fb3b998e472bca3a0c97a86ed Mon Sep 17 00:00:00 2001
From: Spark
Date: Sun, 8 Nov 2026 18:08:35 +0000
Subject: [PATCH 2/2] =?UTF-8?q?Flowdeck=207.85.8=20=E2=80=94=20entr=C3=A9e?=
=?UTF-8?q?=20SSO=20automatique=20sans=20session=20locale=20(chantier=20en?=
=?UTF-8?q?tr=C3=A9e=20SSO,=20empil=C3=A9=20sur=20la=207.85.7)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
La racine, sans session locale et sur instance peuplée, renvoie
au pont /auth/draco quand un cookie draco valide est présenté,
au lieu de servir la page publique déconnectée. Instance vierge
et formulaire local (?provider=local, le chemin prévu) jamais
interceptés ; sans cookie ou cookie invalide, page publique
inchangée. Symétrique du contrôle de cohérence de la 7.85.7
(session locale d'un autre compte).
Tests : tests/test_entree_sso.py (5) ; suites entrée + cohérence
12/12 ; suite complète comparée à la base (voir PR).
---
CHANGELOG.md | 7 ++
VERSION | 2 +-
app/routers/dashboard/workspace.py | 34 +++++++--
tests/test_entree_sso.py | 118 +++++++++++++++++++++++++++++
4 files changed, 154 insertions(+), 7 deletions(-)
create mode 100644 tests/test_entree_sso.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3718aea..c766b3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,10 @@
+## v7.85.8 (2026-11-08) — Entrée SSO automatique sans session locale (chantier « entrée SSO de bout en bout », empilée sur la 7.85.7)
+
+Écart constaté par Bruno en test réel le 2026-11-08 (compte « testeur » créé au Hub) : arrivé chez Flowdeck avec une session centrale valide mais aucune session locale, l'application servait sa page publique déconnectée (« Log in », « Créer un compte dans le Hub ») comme si le cookie draco n'existait pas. Chaîne prouvée en navigateur réel dans une pile locale aux noms publics.
+
+- **Entrée automatique par le pont à la racine** (`app/routers/dashboard/workspace.py`) : sans session locale, si l'instance est peuplée et qu'un cookie `draco_session` **valide** (`app.services.draco.claims_valides`) est présenté, la racine `/` renvoie au pont `/auth/draco?next=/workspaces` (JIT si besoin) au lieu de servir la page publique. **Gardes** : instance vierge (aucun compte) → page publique d'installation inchangée ; cookie absent ou invalide → page publique inchangée ; le pont tranche ensuite (rôle « aucun » → sa page 403 ; échec de résolution → retour au formulaire local `/auth/login?provider=local` — le chemin prévu, celui de tous les liens d'entrée internes — qui n'est jamais intercepté : pas de boucle). Symétrique du contrôle de cohérence de la 7.85.7 (middleware), qui couvre le cas inverse (session locale d'un AUTRE compte).
+- **Tests** : nouveau `tests/test_entree_sso.py` (5) — racine renvoyée au pont, chaîne complète jusqu'à la session ouverte, page publique inchangée sans cookie et avec cookie invalide, formulaire local jamais intercepté. Suites entrée + cohérence : 12/12.
+
## v7.85.7 (2026-11-07) — Cohérence d'identité au changement de compte
Cas réel (capture) : connecté « fred » chez ObsiGate, l'utilisateur ouvre Flowdeck et y trouve son propre compte (BC) — la session locale préexistante gouvernait sans reconsulter l'identité centrale, le pont `/auth/draco` ne s'exécutant qu'à l'entrée SSO sans session locale.
diff --git a/VERSION b/VERSION
index 0146993..7152639 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-7.85.7
+7.85.8
\ No newline at end of file
diff --git a/app/routers/dashboard/workspace.py b/app/routers/dashboard/workspace.py
index 012d80f..8bab807 100644
--- a/app/routers/dashboard/workspace.py
+++ b/app/routers/dashboard/workspace.py
@@ -55,16 +55,38 @@ async def dashboard(
# ── Not authenticated → show landing page ──
if not user:
# Allow through if DB is empty (fresh install)
+ _peuplee = False
try:
with get_conn() as conn:
- count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
- if count == 0:
- from app.templating import ENV
- env = ENV
- template = env.get_template("landing.html")
- return template.render(**_contexte_accueil())
+ _peuplee = conn.execute(
+ "SELECT COUNT(*) FROM users").fetchone()[0] > 0
except Exception:
logger.exception("dashboard")
+ if _peuplee:
+ # Entrée automatique par le pont (7.85.8, chantier
+ # « entrée SSO de bout en bout ») : un cookie draco
+ # VALIDE présenté sans session locale ne doit pas
+ # aboutir à la page publique comme si la session de
+ # la suite n'existait pas — le navigateur est renvoyé
+ # au pont /auth/draco (JIT si besoin), qui revient à
+ # /workspaces. Gardes : instance vierge (aucun compte)
+ # → page publique d'installation, inchangée ; cookie
+ # absent ou invalide → page publique, inchangée ; le
+ # pont tranche ensuite (rôle « aucun » → sa page 403 ;
+ # échec de résolution → retour au formulaire local
+ # /auth/login?provider=local, le chemin prévu, qui
+ # n'est jamais intercepté : pas de boucle). Symétrique
+ # du contrôle de cohérence de la 7.85.7 (middleware),
+ # qui couvre le cas inverse (session locale d'un
+ # AUTRE compte).
+ try:
+ from app.services import draco as _draco_entree
+
+ if _draco_entree.claims_valides(request) is not None:
+ return RedirectResponse(
+ url="/auth/draco?next=/workspaces", status_code=302)
+ except Exception:
+ logger.exception("dashboard — entrée automatique par le pont")
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
diff --git a/tests/test_entree_sso.py b/tests/test_entree_sso.py
new file mode 100644
index 0000000..466e420
--- /dev/null
+++ b/tests/test_entree_sso.py
@@ -0,0 +1,118 @@
+"""Entrée SSO automatique sans session locale (7.85.8).
+
+Chantier « entrée SSO de bout en bout » — écart constaté par
+Bruno en test réel le 2026-11-08 : arrivé chez Flowdeck avec une
+session centrale valide (cookie ``draco_session``) mais aucune
+session locale, l'application servait sa page publique
+déconnectée (« Log in », « Notion-style ») comme
+si le cookie n'existait pas. Attendu : à l'arrivée sur la racine,
+passage automatique par le pont ``/auth/draco`` et service de
+l'application connectée au compte désigné par le cookie.
+
+Couvert : redirection de la racine vers le pont, chaîne complète
+jusqu'à la session ouverte, page publique inchangée sans cookie
+ou avec un cookie invalide, formulaire local jamais intercepté
+(le chemin prévu ``?provider=local``), pont qui tranche en cas de
+rôle « aucun » (403, pas de boucle).
+
+Montage repris de tests/test_coherence_identite.py.
+"""
+
+import base64
+import hashlib
+import hmac
+import json
+import time
+
+import pytest
+
+from app.db import get_conn
+from app.password_utils import hash_password
+from app.services import draco
+
+from tests.conftest import anon
+
+SECRET = "entree-secret"
+SUB_TESTEUR = "ent-sub-testeur"
+MDP = "mot-de-passe-ent"
+
+
+def _jeton(sub, secret=SECRET):
+ 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": "Testeur", "iat": int(time.time()),
+ "exp": int(time.time()) + 3600, "jti": f"jti-{sub}",
+ }).encode())
+ sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
+ hashlib.sha256).digest()
+ return f"{header}.{payload}.{b64(sig)}"
+
+
+@pytest.fixture(autouse=True)
+def _env(monkeypatch):
+ monkeypatch.setenv("DRACO_SESSION_SECRET", SECRET)
+ monkeypatch.setattr(draco, "_refresh_revocations", lambda: None)
+ draco._revoked_cache.clear()
+ yield
+ draco._revoked_cache.clear()
+
+
+@pytest.fixture()
+def compte_lie(client):
+ with get_conn() as conn:
+ cur = conn.execute(
+ "INSERT INTO users (login, email, password_hash, full_name, is_active) "
+ "VALUES (?, ?, ?, ?, 1)",
+ ("enttesteur", "enttesteur@ex.ca", hash_password(MDP),
+ "Testeur Test"))
+ uid = cur.lastrowid
+ conn.execute(
+ "INSERT INTO draco_links (sub, user_id) VALUES (?, ?)",
+ (SUB_TESTEUR, uid))
+ conn.commit()
+ return uid
+
+
+def test_racine_sans_session_cookie_valide_renvoyee_au_pont(client, compte_lie):
+ anon(client)
+ client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
+ r = client.get("/", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers["location"] == "/auth/draco?next=/workspaces"
+
+
+def test_chaine_complete_session_ouverte(client, compte_lie):
+ anon(client)
+ client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers["location"] == "/workspaces"
+ assert "flowdeck_session" in r.headers.get("set-cookie", "")
+ # La racine ne sert plus la page publique.
+ r2 = client.get("/", follow_redirects=False)
+ assert "Notion-style" not in r2.text
+
+
+def test_racine_sans_cookie_page_publique_inchangee(client, compte_lie):
+ anon(client)
+ r = client.get("/", follow_redirects=False)
+ assert r.status_code == 200
+ assert "Notion-style" in r.text
+
+
+def test_racine_cookie_invalide_page_publique_inchangee(client, compte_lie):
+ anon(client)
+ client.cookies.set("draco_session", _jeton(SUB_TESTEUR, secret="mauvais"))
+ r = client.get("/", follow_redirects=False)
+ assert r.status_code == 200
+ assert "Notion-style" in r.text
+
+
+def test_formulaire_local_jamais_intercepte(client, compte_lie):
+ anon(client)
+ client.cookies.set("draco_session", _jeton(SUB_TESTEUR))
+ r = client.get("/auth/login?provider=local", follow_redirects=False)
+ assert r.status_code == 200
--
2.54.0