diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7d21e9f..c4994ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
# Changelog - FlowDeck
+## v7.74.0 (2026-10-21) — Fusion ACC-3.2 : validation locale draco_session et pont de session
+
+### Added
+
+- **Pont SSO `GET /auth/draco`** (Fusion ACC-3.2, issue #21) : le
+ cookie `draco_session` émis par ObsiGate (ACC-3.1) est vérifié
+ localement (`app/services/draco.py` — JWS HS256 au secret partagé
+ `DRACO_SESSION_SECRET`, révocation de l'émettrice en cache 60 s)
+ puis converti en session Flowdeck native.
+- **Correspondance `sub` → compte local** : migration **51**
+ (`draco_links`, association 1:1) — lien créé automatiquement à la
+ première visite où la session native et un cookie draco valide
+ coexistent ; repli mono-utilisateur sans lien ;
+ `GET/POST/DELETE /auth/draco-link` pour consulter, ré-associer et
+ dissocier (pierre tombale anti re-création automatique).
+- Bouton « Login with ObsiGate (SSO) » sur la page de connexion
+ (via `GET /sso/authorize` de l'émettrice, retour sur `/auth/draco`).
+- Inerte tant que `DRACO_SESSION_SECRET` n'est pas configuré.
+ Tests : `tests/test_fusion_draco.py`.
+
## v7.73.0 (2026-10-20) — Fusion ACC-2.1 : connecteur MCP Flowdeck → TikTak
### Added
diff --git a/VERSION b/VERSION
index 55ba97f..c08d15d 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-7.73.0
+7.74.0
diff --git a/app/main.py b/app/main.py
index 6332b3a..95299cb 100644
--- a/app/main.py
+++ b/app/main.py
@@ -192,7 +192,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
- version="7.73.0",
+ version="7.74.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
diff --git a/app/migrations.py b/app/migrations.py
index b7080d3..a7ba1f8 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -2059,3 +2059,20 @@ def _migration_external_refs(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_external_refs_owner "
"ON external_refs(owner_type, owner_id)"
)
+
+
+@register(51, "fusion: liens d'identité draco_links (ACC-3.2)")
+def _migration_draco_links(conn: sqlite3.Connection) -> None:
+ """Migration 51 — correspondance sub (cookie SSO draco_session,
+ émis par ObsiGate) → compte Flowdeck local. Association 1:1 ;
+ `unlinked_at` sert de pierre tombale après dissociation."""
+ conn.execute(
+ """
+ CREATE TABLE IF NOT EXISTS draco_links (
+ sub TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ unlinked_at TEXT
+ )
+ """
+ )
diff --git a/app/routers/auth.py b/app/routers/auth.py
index 1203c0e..d9755e6 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -102,7 +102,8 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
Or continue with
-
+
+
@@ -451,6 +452,63 @@ async def callback(
return response
+# ── Fusion ACC-3.2 — pont SSO draco_session ──────────────────────────────
+
+@router.get("/draco")
+def draco_bridge(request: Request, next: str = "/workspaces"):
+ """Convertit le cookie SSO ``draco_session`` (ObsiGate) en session.
+
+ Point de retour de ``GET /sso/authorize`` de l'émettrice : si le
+ cookie est valide et résolu vers un compte local, une session
+ Flowdeck native est créée ; sinon, retour à la connexion locale.
+ """
+ from app.services import draco
+
+ user = draco.user_for_draco(request)
+ if user is None:
+ return RedirectResponse(url="/auth/login?provider=local", status_code=302)
+ session = SessionManager.create_session(user, request)
+ _log_login(user["id"], request)
+ cible = next if next.startswith("/") and not next.startswith("//") else "/workspaces"
+ response = RedirectResponse(url=cible, status_code=302)
+ response.set_cookie("flowdeck_session", session, httponly=True,
+ max_age=86400 * 7, samesite="lax", path="/")
+ return response
+
+
+@router.get("/draco-link")
+def draco_link_get(request: Request):
+ """Sub draco associé au compte courant (ou null)."""
+ from app.services import draco
+
+ user = _session_user_or_401(request)
+ return {"sub": draco.current_link(user["id"])}
+
+
+@router.post("/draco-link")
+def draco_link_post(request: Request):
+ """(Ré)associe le sub du cookie draco de la requête au compte."""
+ from fastapi import HTTPException
+
+ from app.services import draco
+
+ user = _session_user_or_401(request)
+ sub = draco.relink(request, user)
+ if sub is None:
+ raise HTTPException(400, "Cookie draco_session absent, invalide ou déjà lié ailleurs")
+ return {"sub": sub}
+
+
+@router.delete("/draco-link")
+def draco_link_delete(request: Request):
+ """Dissocie le compte (pierre tombale anti re-création automatique)."""
+ from app.services import draco
+
+ user = _session_user_or_401(request)
+ draco.unlink(user["id"])
+ return {"ok": True}
+
+
@router.get("/logout")
def logout(request: Request):
"""Clear session and redirect to login page.
@@ -479,6 +537,11 @@ def current_user(request: Request):
user = gcu(request)
if not user:
return {"authenticated": False}
+ # Fusion ACC-3.2 : si un cookie draco valide accompagne la session
+ # native, l'association sub ↔ compte se crée à cette occasion.
+ from app.services import draco
+
+ draco.maybe_link(request, user)
return {"authenticated": True, "user": public_user(user)}
diff --git a/app/services/draco.py b/app/services/draco.py
new file mode 100644
index 0000000..9e1c4d4
--- /dev/null
+++ b/app/services/draco.py
@@ -0,0 +1,217 @@
+"""Validation locale du cookie SSO ``draco_session`` — Fusion ACC-3.2.
+
+Flowdeck valide localement le jeton émis par ObsiGate (l'émettrice,
+ACC-3.1) : JWS HS256 vérifié avec le secret partagé
+(``DRACO_SESSION_SECRET``, ``_PREV`` accepté en rotation), expiration,
+et liste de révocation de l'émettrice (cache 60 s, interrogée
+seulement si un cookie draco est présenté). Vérification faite avec
+hmac/hashlib, algorithme épinglé — mêmes sémantiques que le
+validateur de TikTak.
+
+Le pont ``GET /auth/draco`` (routers/auth.py) convertit un cookie
+valide en session Flowdeck native. Correspondance ``sub`` → compte
+local : table ``draco_links`` (migration 51), créée automatiquement
+à la première visite où la session native et un cookie draco valide
+coexistent (association 1:1, dissociation = pierre tombale) ; à
+défaut de lien, une instance à un seul utilisateur actif associe ce
+sub à cet unique compte. Sans secret configuré, module inerte.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+import logging
+import os
+import time
+
+from app.db import get_conn
+
+logger = logging.getLogger(__name__)
+
+COOKIE_NAME = "draco_session"
+_REVOCATIONS_TTL = 60 # secondes de cache, comme le prévoit le design
+
+_revoked_cache: dict[str, int] = {}
+_revoked_fetched_at: float = 0.0
+
+
+def _secret() -> str:
+ return os.environ.get("DRACO_SESSION_SECRET", "")
+
+
+def _secret_prev() -> str:
+ return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
+
+
+def enabled() -> bool:
+ return bool(_secret())
+
+
+def _b64decode(segment: str) -> bytes:
+ return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4))
+
+
+def verify_token(token: str) -> dict | None:
+ """Vérifie un JWS HS256 (signature, alg épinglé, expiration)."""
+ if not token or not enabled():
+ return None
+ parts = token.split(".")
+ if len(parts) != 3:
+ return None
+ try:
+ header = json.loads(_b64decode(parts[0]))
+ if not isinstance(header, dict) or header.get("alg") != "HS256":
+ return None
+ signing_input = f"{parts[0]}.{parts[1]}".encode("ascii")
+ signature = _b64decode(parts[2])
+ claims = json.loads(_b64decode(parts[1]))
+ except Exception:
+ return None
+ if not isinstance(claims, dict):
+ return None
+ for secret in (_secret(), _secret_prev()):
+ if not secret:
+ continue
+ attendu = hmac.new(secret.encode("utf-8"), signing_input,
+ hashlib.sha256).digest()
+ if hmac.compare_digest(attendu, signature):
+ break
+ else:
+ return None
+ try:
+ if int(claims.get("exp", 0)) <= int(time.time()):
+ return None
+ except (TypeError, ValueError):
+ return None
+ return claims
+
+
+# ── Révocations (cache 60 s alimenté par l'émettrice) ───────────────────
+
+def _refresh_revocations() -> None:
+ global _revoked_fetched_at
+ now = time.monotonic()
+ if now - _revoked_fetched_at < _REVOCATIONS_TTL:
+ return
+ _revoked_fetched_at = now
+ base = os.environ.get("DRACO_SSO_URL", "https://og.dracodev.net")
+ try:
+ import httpx
+
+ headers = {}
+ machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
+ if machine_key:
+ headers["X-Draco-Machine-Key"] = machine_key
+ r = httpx.get(f"{base}/api/sso/revocations", headers=headers,
+ timeout=2.0)
+ if r.status_code == 200:
+ _revoked_cache.clear()
+ for item in r.json().get("revoked", []):
+ _revoked_cache[str(item["jti"])] = int(item["exp"])
+ except Exception as exc: # l'émettrice injoignable ne bloque pas le SSO
+ logger.debug("Révocations draco indisponibles: %s", exc)
+
+
+def is_revoked(jti: str) -> bool:
+ _refresh_revocations()
+ return jti in _revoked_cache
+
+
+# ── Résolution sub → utilisateur local ──────────────────────────────────
+
+def _linked_user(conn, sub: str) -> dict | None:
+ row = conn.execute(
+ "SELECT u.* FROM draco_links l JOIN users u ON u.id = l.user_id "
+ "WHERE l.sub = ? AND l.unlinked_at IS NULL",
+ (sub,),
+ ).fetchone()
+ if row is not None:
+ return dict(row)
+ users = conn.execute("SELECT * FROM users WHERE is_active = 1").fetchall()
+ if len(users) == 1:
+ return dict(users[0])
+ return None
+
+
+def user_for_draco(request) -> dict | None:
+ """Utilisateur Flowdeck prouvé par le cookie draco_session, ou None."""
+ 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
+ sub = str(claims.get("sub", ""))
+ if not sub:
+ return None
+ with get_conn() as conn:
+ user = _linked_user(conn, sub)
+ if user is not None and not user.get("is_active"):
+ return None
+ return user
+
+
+def maybe_link(request, user: dict | None) -> None:
+ """Associe sub ↔ utilisateur quand les deux sessions coexistent (1:1)."""
+ if not enabled() or not user:
+ return
+ claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
+ if not claims:
+ return
+ sub = str(claims.get("sub", ""))
+ if not sub:
+ return
+ try:
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT OR IGNORE INTO draco_links (sub, user_id) VALUES (?, ?)",
+ (sub, user["id"]),
+ )
+ conn.commit()
+ except Exception as exc: # l'association ne casse jamais la requête
+ logger.debug("Lien draco non créé: %s", exc)
+
+
+def current_link(user_id: int) -> str | None:
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT sub FROM draco_links WHERE user_id = ? AND unlinked_at IS NULL",
+ (user_id,),
+ ).fetchone()
+ return row["sub"] if row else None
+
+
+def unlink(user_id: int) -> None:
+ """Dissocie : pierre tombale (empêche la re-création automatique)."""
+ with get_conn() as conn:
+ conn.execute(
+ "UPDATE draco_links SET unlinked_at = datetime('now') WHERE user_id = ?",
+ (user_id,),
+ )
+ conn.commit()
+
+
+def relink(request, user: dict) -> str | None:
+ """(Ré)associe le sub du cookie draco de la requête au compte.
+
+ Retourne le sub associé, ou None si le cookie est invalide ou si
+ le sub est déjà lié à un autre compte.
+ """
+ claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
+ if not claims or not claims.get("sub"):
+ return None
+ sub = str(claims["sub"])
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT INTO draco_links (sub, user_id, unlinked_at) VALUES (?, ?, NULL) "
+ "ON CONFLICT(sub) DO UPDATE SET unlinked_at = NULL "
+ "WHERE draco_links.user_id = excluded.user_id",
+ (sub, user["id"]),
+ )
+ conn.commit()
+ return sub if current_link(user["id"]) == sub else None
diff --git a/tests/test_fusion_draco.py b/tests/test_fusion_draco.py
new file mode 100644
index 0000000..bf234d7
--- /dev/null
+++ b/tests/test_fusion_draco.py
@@ -0,0 +1,113 @@
+"""Fusion ACC-3.2 — validation locale draco_session et pont de session.
+
+Le cookie émis par ObsiGate (ACC-3.1) est vérifié localement
+(secret partagé, HS256), résolu vers le compte Flowdeck
+(draco_links, migration 51 ; repli mono-utilisateur), et converti en
+session native par GET /auth/draco. Sans DRACO_SESSION_SECRET, le
+pont renvoie simplement à la connexion locale.
+"""
+
+import base64
+import hashlib
+import hmac
+import json
+import time
+
+import pytest
+
+from app.db import get_conn
+from app.services import draco
+
+SECRET = "secret-partage-de-test"
+
+
+def _jeton(sub="admin-1", secret=SECRET, exp=None, jti="jti-test"):
+ def b64(d: bytes) -> str:
+ return base64.urlsafe_b64encode(d).rstrip(b"=").decode()
+
+ header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
+ payload = b64(json.dumps({
+ "sub": sub, "name": "Bruno", "iat": int(time.time()),
+ "exp": exp if exp is not None else int(time.time()) + 3600,
+ "jti": jti,
+ }).encode())
+ sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(),
+ hashlib.sha256).digest()
+ return f"{header}.{payload}.{b64(sig)}"
+
+
+@pytest.fixture(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 test_verify_token():
+ assert draco.verify_token(_jeton())["sub"] == "admin-1"
+ assert draco.verify_token(_jeton(secret="mauvais")) is None
+ assert draco.verify_token(_jeton(exp=int(time.time()) - 5)) is None
+ assert draco.verify_token("pas.un.jeton") is None
+
+
+def test_pont_sans_cookie_vers_login(client):
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.status_code == 302
+ assert r.headers["location"] == "/auth/login?provider=local"
+
+
+def test_pont_cookie_valide_cree_session(client):
+ client.cookies.set("draco_session", _jeton(sub="admin-1"))
+ 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", "")
+ me = client.get("/auth/user")
+ assert me.json()["authenticated"] is True
+
+
+def test_pont_cookie_falsifie_vers_login(client):
+ client.cookies.set("draco_session", _jeton(secret="mauvais"))
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.headers["location"] == "/auth/login?provider=local"
+
+
+def test_pont_inerte_sans_secret(client, monkeypatch):
+ monkeypatch.delenv("DRACO_SESSION_SECRET")
+ client.cookies.set("draco_session", _jeton())
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.headers["location"] == "/auth/login?provider=local"
+
+
+def test_lien_automatique_et_gestion(client):
+ client.cookies.set("draco_session", _jeton(sub="og-bruno-1"))
+ client.get("/auth/user") # visite doublement connectée
+ r = client.get("/auth/draco-link")
+ assert r.json()["sub"] == "og-bruno-1"
+ r = client.delete("/auth/draco-link")
+ assert r.json()["ok"] is True
+ assert client.get("/auth/draco-link").json()["sub"] is None
+ client.get("/auth/user") # ne recrée pas le lien
+ assert client.get("/auth/draco-link").json()["sub"] is None
+ r = client.post("/auth/draco-link") # ré-association explicite
+ assert r.json()["sub"] == "og-bruno-1"
+
+
+def test_lien_requis_en_multi_utilisateurs(client):
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT INTO users (login, email, password_hash, full_name, is_active) "
+ "VALUES ('deuxieme', 'deux@example.com', 'x', 'Deuxième', 1)")
+ conn.commit()
+ # Sans lien et avec deux utilisateurs actifs : le pont refuse.
+ client.cookies.set("draco_session", _jeton(sub="og-inconnu"))
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.headers["location"] == "/auth/login?provider=local"
+ # Le lien explicite (créé pendant une visite connectée) rouvre la porte.
+ client.cookies.set("draco_session", _jeton(sub="og-bruno-1"))
+ assert client.post("/auth/draco-link").json()["sub"] == "og-bruno-1"
+ client.cookies.delete("flowdeck_session")
+ r = client.get("/auth/draco", follow_redirects=False)
+ assert r.headers["location"] == "/workspaces"