"""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. HUB-15 (plan Hub v2.1, §2.2) — provisionnement JIT : si aucun compte local ne correspond au ``sub`` (ni lien, ni repli mono-utilisateur), le pendant local est **créé automatiquement** à la première connexion draco, depuis les claims (``sub``, ``name``), avec le rôle par défaut sûr : utilisateur standard (``is_admin = 0``, jamais admin), aucun mot de passe local (``password_hash`` NULL, ``auth_method = 'draco'`` — l'authentification passe par draco, la connexion locale par mot de passe reste impossible pour ce compte et inchangée pour les autres). Une pierre tombale (dissociation explicite) interdit toute re-création automatique pour ce ``sub``. """ 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 from app.services import draco_urls base = draco_urls.obsigate_url() 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 _jit_login(sub: str) -> str: """Login local déterministe dérivé du ``sub`` draco. Préfixe ``draco-`` (aucune collision possible avec un login choisi par un humain ou par l'OAuth, préfixés par fournisseur), caractères restreints à ``[a-z0-9._-]``. """ nettoye = "".join( c if (c.isascii() and (c.isalnum() or c in "._-")) else "-" for c in sub.strip().lower() ).strip("-") return f"draco-{nettoye}" if nettoye else "draco-compte" def _provision_jit(conn, claims: dict) -> dict | None: """Crée le pendant local d'un ``sub`` draco inconnu (HUB-15). Création + liaison dans la même transaction. Retourne le nouvel utilisateur, ou None si la création ne doit pas avoir lieu : une ligne ``draco_links`` existe déjà pour ce ``sub`` (lien actif — déjà résolu par ``_linked_user`` —, compte lié inactif, ou pierre tombale d'une dissociation explicite : on ne recrée jamais automatiquement derrière une dissociation). """ sub = str(claims.get("sub", "")) if not sub: return None deja = conn.execute( "SELECT user_id FROM draco_links WHERE sub = ?", (sub,) ).fetchone() if deja is not None: return None nom = str(claims.get("name", "") or "").strip() login = _jit_login(sub) # Collision résiduelle : un login identique pris par un autre # compte (non lié) — on suffixe par une empreinte courte du sub # plutôt que de lier ou d'écraser quoi que ce soit. if conn.execute( "SELECT id FROM users WHERE login = ?", (login,) ).fetchone() is not None: suffixe = hashlib.sha256(sub.encode("utf-8")).hexdigest()[:8] login = f"{login}-{suffixe}" try: cur = conn.execute( "INSERT INTO users (login, full_name, email, password_hash, " "is_admin, is_active, auth_method) " "VALUES (?, ?, '', NULL, 0, 1, 'draco')", (login, nom or login), ) conn.execute( "INSERT INTO draco_links (sub, user_id) VALUES (?, ?)", (sub, cur.lastrowid), ) conn.commit() except Exception as exc: # la création ne casse jamais la requête logger.debug("Provisionnement JIT draco refusé: %s", exc) conn.rollback() return None row = conn.execute( "SELECT * FROM users WHERE id = ?", (cur.lastrowid,) ).fetchone() logger.info("Compte local créé en JIT pour le sub draco %s (login %s)", sub, login) return dict(row) if row is not None else None def user_for_draco(request) -> dict | None: """Utilisateur Flowdeck prouvé par le cookie draco_session, ou None. Résolution : lien ``draco_links`` existant, sinon repli mono-utilisateur, sinon provisionnement JIT (HUB-15) — le pendant local est créé depuis les claims et lié au ``sub``. """ 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 None: user = _provision_jit(conn, claims) 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