Compare commits

...
3 Commits
Author SHA1 Message Date
bruno c1aa6697f0 Merge pull request 'Flowdeck 7.85.7 — cohérence d'identité au changement de compte' (#59) from coherence-identite-session-draco into main
FlowDeck CI / test (push) Failing after 13m13s
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
2026-11-08 14:43:23 -05:00
Spark f8b228396d Flowdeck 7.85.8 — entrée SSO automatique sans session locale (chantier entrée SSO, empilé sur la 7.85.7)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / lint (pull_request) Failing after 2s
FlowDeck CI / test (pull_request) Failing after 10m30s
FlowDeck CI / docker (pull_request) Skipped
FlowDeck CI / test (push) Failing after 3h1m6s
FlowDeck CI / docker (push) Skipped
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).
2026-11-08 18:08:35 +00:00
Spark a4852d3889 Flowdeck 7.85.7 — cohérence d'identité au changement de compte : la session locale s'aligne sur le cookie draco (middleware pages, marqueurs de session draco_sub/secours)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
2026-11-08 04:34:10 +00:00
10 changed files with 551 additions and 14 deletions
+16
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
7.85.6
7.85.8
+43 -5
View File
@@ -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:
+8
View File
@@ -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)
+110
View File
@@ -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
+13 -2
View File
@@ -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.</p>
)
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)
+28 -6
View File
@@ -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")
+24
View File
@@ -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:
+190
View File
@@ -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")
+118
View File
@@ -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", "[email protected]", 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