diff --git a/CHANGELOG.md b/CHANGELOG.md index d23bb30..c766b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 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. + +- **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..7152639 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.85.6 +7.85.8 \ No newline at end of file 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/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/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") 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