feat: Fusion ACC-3.2 — pont SSO draco_session (/auth/draco), liens sub → compte (migration 51) (#21)
This commit is contained in:
@@ -1,5 +1,25 @@
|
|||||||
# Changelog - FlowDeck
|
# 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
|
## v7.73.0 (2026-10-20) — Fusion ACC-2.1 : connecteur MCP Flowdeck → TikTak
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.73.0",
|
version="7.74.0",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -2059,3 +2059,20 @@ def _migration_external_refs(conn: sqlite3.Connection) -> None:
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_external_refs_owner "
|
"CREATE INDEX IF NOT EXISTS idx_external_refs_owner "
|
||||||
"ON external_refs(owner_type, owner_id)"
|
"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
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|||||||
+64
-1
@@ -102,7 +102,8 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
|
|||||||
<div class="oauth-section" id="oauth-section">
|
<div class="oauth-section" id="oauth-section">
|
||||||
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
|
||||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
|
||||||
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'" style="margin-bottom:8px">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
|
||||||
|
<button class="oauth-btn" onclick="window.location='https://og.dracodev.net/sso/authorize?return_to=https%3A%2F%2Fflowdeck.dracodev.net%2Fauth%2Fdraco'">🧭 Login with ObsiGate (SSO)</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
|
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
|
||||||
<div class="oauth-section" id="sso-section" style="display:none">
|
<div class="oauth-section" id="sso-section" style="display:none">
|
||||||
@@ -451,6 +452,63 @@ async def callback(
|
|||||||
return response
|
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")
|
@router.get("/logout")
|
||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
"""Clear session and redirect to login page.
|
"""Clear session and redirect to login page.
|
||||||
@@ -479,6 +537,11 @@ def current_user(request: Request):
|
|||||||
user = gcu(request)
|
user = gcu(request)
|
||||||
if not user:
|
if not user:
|
||||||
return {"authenticated": False}
|
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)}
|
return {"authenticated": True, "user": public_user(user)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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', '[email protected]', '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"
|
||||||
Reference in New Issue
Block a user