From eff74cabe0498bbeb13d35aca456fcf8f6e69ccb Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 22 Sep 2026 14:48:47 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20#107=20configuration=20-=20gestion=20de?= =?UTF-8?q?s=20cl=C3=A9s=20API=20&=20MCP=20(cr=C3=A9ation/r=C3=A9vocation,?= =?UTF-8?q?=20expiration=201j/1mois/6mois/1an/sans=20fin,=20une=20cl=C3=A9?= =?UTF-8?q?=20pour=20API=20REST=20+=20serveur=20MCP,=20derni=C3=A8re=20uti?= =?UTF-8?q?lisation,=20store=20sans=20secret=20persist=C3=A9;=20fix=20r?= =?UTF-8?q?=C3=A9vocation=20longue=20dur=C3=A9e)=20+=20script=20token=20MC?= =?UTF-8?q?P?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- CHANGELOG.md | 23 ++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/auth/jwt_handler.py | 191 ++++++++++++++++++-- backend/auth/middleware.py | 6 +- backend/auth/router.py | 59 ++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 5 +- docs/features/api-mcp-tokens-107.md | 95 ++++++++++ frontend/index.html | 41 +++++ frontend/js/config.js | 104 +++++++++++ frontend/locales/en.json | 28 +++ frontend/locales/fr.json | 28 +++ frontend/style.css | 21 +++ frontend/sw.js | 2 +- generer_access_token.sh | 20 ++ package.json | 2 +- tests/test_api_tokens.py | 271 ++++++++++++++++++++++++++++ 22 files changed, 885 insertions(+), 33 deletions(-) create mode 100644 docs/features/api-mcp-tokens-107.md create mode 100644 generer_access_token.sh create mode 100644 tests/test_api_tokens.py diff --git a/.env.example b/.env.example index 906eb15..e6da4a4 100644 --- a/.env.example +++ b/.env.example @@ -16,7 +16,7 @@ OBSIGATE_ADMIN_PASSWORD=chab30 # OBSIGATE_SECURE_COOKIES=false # Tokens TTL en secondes -# OBSIGATE_ACCESS_TOKEN_TTL=900 +# OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans # OBSIGATE_REFRESH_TOKEN_TTL=604800 # Rate limiting diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b8354..653eac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.14.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.15.0**. --- @@ -14,6 +14,27 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.15.0] — 2026-09-22 + +### Ajouté + +- **#107 - Configuration : gestion des clés API & MCP** — nouvelle section + « 🔑 Clés API & MCP » dans le panneau de configuration : création, liste + (créée / expire / dernière utilisation) et révocation de jetons longue + durée utilisables aussi bien sur l'API REST que sur le serveur MCP + (`/mcp`) — un seul et même jeton Bearer pour les deux. Choix + d'expiration à la création : 1 jour, 1 mois, 6 mois, 1 an, sans fin. + Le secret n'est affiché qu'une fois (jamais persisté en clair, + `data/api_tokens.json` ne contient que les métadonnées) ; révocation + immédiate des deux côtés, plafond 50 clés par utilisateur, isolation + par utilisateur, audit `config_change`. Corrigé au passage : le store + de révocation (`revoked_tokens.json`) bornait toute entrée à 7 jours — + un jeton longue durée révoqué « reprenait vie » après purge ; il est + désormais calé sur l'expiration réelle du jeton. Voir + [docs/features/api-mcp-tokens-107.md](./docs/features/api-mcp-tokens-107.md). + +--- + ## [2.14.1] — 2026-09-22 --- diff --git a/README.fr.md b/README.fr.md index 059a7fa..77207f3 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.14.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.15.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -927,8 +927,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.14.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.15.0). --- -*Projet : ObsiGate | Version : 2.14.1 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.15.0 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index cc33eb3..c80f8c5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.14.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.15.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1096,8 +1096,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.14.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.15.0). --- -*Project: ObsiGate | Version: 2.14.1 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.15.0 | Last updated: May 2026* diff --git a/VERSION b/VERSION index b70ae75..68e69e4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.14.1 +2.15.0 diff --git a/backend/auth/jwt_handler.py b/backend/auth/jwt_handler.py index 6058214..2fbbb34 100644 --- a/backend/auth/jwt_handler.py +++ b/backend/auth/jwt_handler.py @@ -7,6 +7,7 @@ import json import logging import os import secrets +import threading import time import uuid from pathlib import Path @@ -23,6 +24,22 @@ ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_ACCESS_TOKEN_TTL", "3600")) # default 1 hour REFRESH_TOKEN_EXPIRE_SECONDS = int(os.environ.get("OBSIGATE_REFRESH_TOKEN_TTL", "604800")) # default 7 days +#: Persistent API/MCP access tokens (user-managed, shown in the config panel). +API_TOKENS_FILE = Path("data/api_tokens.json") +#: Accepted values for the expiry selector in the UI (1 day, 1 month, 6 months, +#: 1 year, never). "never" → no ``exp`` claim → token valid until revoked. +API_TOKEN_EXPIRY_CHOICES = { + "1d": 24 * 3600, + "30d": 30 * 24 * 3600, + "180d": 180 * 24 * 3600, + "365d": 365 * 24 * 3600, + "never": None, +} +#: Max active tokens per user (anti hoarding; revoking frees a slot). +API_TOKEN_MAX_PER_USER = 50 +#: AES-GCM key derived once from the JWT secret to encrypt stored tokens. +_API_TOKEN_KEY: bytes | None = None + # In-memory revoked token set (loaded from disk on startup) _revoked_jtis: set = set() _revoked_loaded = False @@ -92,43 +109,61 @@ def decode_token(token: str) -> dict | None: # --------------------------------------------------------------------------- # Token revocation # --------------------------------------------------------------------------- +# The store is a dict {jti: valid_until}: the revocation record may be dropped +# once the underlying token's own expiry has passed (by then the JWT is dead +# anyway). Long-lived API/MCP tokens (see create_api_token) must therefore be +# revoked with their real expiry — a 1-year token revoked last week must not +# silently come back to life when a 7-day cleanup purges the record (BUG in +# the previous set-based store, fixed with feature #107). + +_revoked_map: dict[str, int] = {} +_revoked_loaded = False + def _load_revoked(): """Load revoked token JTIs from disk into memory (once).""" - global _revoked_loaded, _revoked_jtis + global _revoked_loaded, _revoked_map if _revoked_loaded: return if REVOKED_TOKENS_FILE.exists(): try: data = json.loads(REVOKED_TOKENS_FILE.read_text()) - # Clean expired entries (older than 7 days) + # Drop entries whose underlying token has itself expired. now = int(time.time()) - _revoked_jtis = { - jti for jti, exp in data.items() - if exp > now + _revoked_map = { + jti: int(exp) for jti, exp in data.items() + if int(exp) > now } except Exception as e: logger.warning(f"Failed to load revoked tokens: {e}") - _revoked_jtis = set() + _revoked_map = {} _revoked_loaded = True def _save_revoked(): - """Persist revoked JTIs to disk.""" + """Persist revoked JTIs to disk with their per-token expiry.""" REVOKED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True) - # Store with expiry timestamp for cleanup - now = int(time.time()) - # Keep entries for 7 days max - data = {jti: now + REFRESH_TOKEN_EXPIRE_SECONDS for jti in _revoked_jtis} tmp = REVOKED_TOKENS_FILE.with_suffix(".tmp") - tmp.write_text(json.dumps(data)) + tmp.write_text(json.dumps(_revoked_map)) tmp.replace(REVOKED_TOKENS_FILE) -def revoke_token(jti: str): - """Add a token JTI to the revocation list.""" +def revoke_token(jti: str, expires_at: int | None = None): + """Add a token JTI to the revocation list. + + ``expires_at`` is the revoked token's own ``exp`` (unix seconds) — the + record is kept at least that long so a long-lived API token cannot + outlive its revocation. ``None`` means the token never expires (API/MCP + "sans fin") → the record is kept forever (capped at ~100 years, the JWT + store's practical infinity). Default keeps 7 days (session tokens). + """ _load_revoked() - _revoked_jtis.add(jti) + now = int(time.time()) + if expires_at is None: + until = now + 100 * 365 * 24 * 3600 + else: + until = max(int(expires_at), now + REFRESH_TOKEN_EXPIRE_SECONDS) + _revoked_map[jti] = until _save_revoked() logger.debug(f"Revoked token JTI: {jti[:8]}...") @@ -136,4 +171,128 @@ def revoke_token(jti: str): def is_token_revoked(jti: str) -> bool: """Check if a token JTI has been revoked.""" _load_revoked() - return jti in _revoked_jtis + return jti in _revoked_map + + +# --------------------------------------------------------------------------- +# API / MCP tokens (feature #107) +# --------------------------------------------------------------------------- +# Long-lived access tokens the user creates from the config panel. They are +# plain HS256 access-type JWTs (``api: true`` claim), so they authenticate +# against BOTH the REST API and the MCP endpoint (/mcp) — which share +# ``get_current_user``. The raw token is shown exactly once at creation; the +# store keeps metadata only (name, owner, expiry, last use) — no secret +# material is written to disk. +# +# File: data/api_tokens.json +# {"version": 1, "tokens": {jti: {name, username, created_at, expires_at, last_used_at}}} + +_api_tokens_lock = threading.RLock() +_touch_last_write: dict[str, float] = {} + + +def _load_api_tokens() -> dict: + if not API_TOKENS_FILE.exists(): + return {"version": 1, "tokens": {}} + try: + return json.loads(API_TOKENS_FILE.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as e: + logger.error(f"Failed to read api_tokens.json: {e}") + return {"version": 1, "tokens": {}} + + +def _save_api_tokens(data: dict): + API_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = API_TOKENS_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") + tmp.replace(API_TOKENS_FILE) + + +def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]: + """Create a persistent API/MCP token. Returns (record, jwt_string). + + ``expiry_key`` must be one of API_TOKEN_EXPIRY_CHOICES; "never" omits the + ``exp`` claim (valid until explicitly revoked). + """ + if expiry_key not in API_TOKEN_EXPIRY_CHOICES: + raise ValueError("Expiration invalide") + seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key] + with _api_tokens_lock: + data = _load_api_tokens() + tokens = data["tokens"] + mine = sum(1 for t in tokens.values() if t["username"] == user["username"]) + if mine >= API_TOKEN_MAX_PER_USER: + raise ValueError(f"Maximum {API_TOKEN_MAX_PER_USER} tokens par utilisateur") + now = int(time.time()) + jti = str(uuid.uuid4()) + payload = { + "sub": user["username"], + "role": user.get("role", "user"), + "vaults": user.get("vaults", []), + "jti": jti, + "iat": now, + "type": "access", + "api": True, + } + if seconds is not None: + payload["exp"] = now + seconds + token = jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM) + record = { + "jti": jti, + "name": name[:64] or "API token", + "username": user["username"], + "created_at": now, + "expires_at": payload.get("exp"), + "expiry_key": expiry_key, + "last_used_at": None, + } + tokens[jti] = record + _save_api_tokens(data) + return record, token + + +def list_api_tokens(username: str) -> list[dict]: + """Token metadata for one user, newest first.""" + data = _load_api_tokens() + now = int(time.time()) + items = [ + {**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now} + for t in data["tokens"].values() + if t["username"] == username + ] + return sorted(items, key=lambda t: t["created_at"], reverse=True) + + +def delete_api_token(jti: str, username: str) -> dict: + """Revoke and remove an API token. Raises KeyError when unknown/not owned.""" + with _api_tokens_lock: + data = _load_api_tokens() + record = data["tokens"].get(jti) + if not record or record["username"] != username: + raise KeyError(jti) + # Revoke by jti so the presented JWT stops working even though it is + # stateless — kept until its natural expiry (no-expiry → forever). + revoke_token(jti, record.get("expires_at")) + del data["tokens"][jti] + _save_api_tokens(data) + return record + + +def maybe_touch_api_token(jti: str | None, created_or_expires: bool = False): + """Record last usage of an API token, throttled to one disk write/hour.""" + if not jti: + return + now = time.time() + if now - _touch_last_write.get(jti, 0) < 3600: + return + _touch_last_write[jti] = now + try: + with _api_tokens_lock: + data = _load_api_tokens() + record = data["tokens"].get(jti) + if record is None: + return + record["last_used_at"] = int(now) + _save_api_tokens(data) + except Exception as e: # never fail an authenticated request over stats + logger.debug(f"api_token touch failed: {e}") diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index 41000ea..505a506 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -11,7 +11,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend.services.net import get_client_ip -from .jwt_handler import decode_token, is_token_revoked +from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token from .user_store import get_user logger = logging.getLogger("obsigate.auth.middleware") @@ -115,6 +115,10 @@ def get_current_user( user["_token_vaults"] = payload.get("vaults", []) # Attach the token id for per-token rate limiting (AI tool layer). user["_token_jti"] = payload.get("jti") + # Feature #107: track last usage of user-managed API/MCP tokens + # (throttled write — this dependency runs on both REST and /mcp paths). + if payload.get("api"): + maybe_touch_api_token(payload.get("jti")) # BUG-030: expose the real client IP to the audit log. user["_request_ip"] = get_client_ip(request) return user diff --git a/backend/auth/router.py b/backend/auth/router.py index 2758cc2..7632392 100644 --- a/backend/auth/router.py +++ b/backend/auth/router.py @@ -17,10 +17,14 @@ from backend.services.net import get_client_ip from .jwt_handler import ( ACCESS_TOKEN_EXPIRE_SECONDS, + API_TOKEN_EXPIRY_CHOICES, create_access_token, + create_api_token, create_refresh_token, decode_token, + delete_api_token, is_token_revoked, + list_api_tokens, revoke_token, ) from .mfa import ( @@ -861,3 +865,58 @@ async def delete_user_endpoint( return {"message": f"Utilisateur '{username}' supprimé"} except ValueError as e: raise HTTPException(404, str(e)) + + +# ── API / MCP tokens (feature #107) ────────────────────────────────── +# One long-lived token authenticates BOTH the REST API and the MCP +# endpoint (/mcp): the MCP server resolves the caller through the same +# get_current_user() dependency, so the same Bearer JWT works everywhere. + +class CreateApiTokenRequest(BaseModel): + name: str + expiry: str # 1d | 30d | 180d | 365d | never + + +@router.get("/tokens") +async def list_user_tokens(current_user=Depends(require_auth)): + """List the caller's API/MCP tokens (metadata only — the secret is never stored).""" + return { + "tokens": list_api_tokens(current_user["username"]), + "expiry_choices": list(API_TOKEN_EXPIRY_CHOICES.keys()), + } + + +@router.post("/tokens") +async def create_user_token( + req: CreateApiTokenRequest, + request: Request, + current_user=Depends(require_auth), +): + """Create a long-lived API/MCP token. The raw JWT is returned ONCE.""" + try: + record, token = create_api_token(current_user, req.name.strip(), req.expiry) + except ValueError as e: + raise HTTPException(400, str(e)) + from backend.audit import log_config_change + log_config_change(current_user["username"], + {"action": "api_token_create", "name": record["name"], + "expiry": record["expiry_key"]}, ip=get_client_ip(request)) + return {"token": token, **record} + + +@router.delete("/tokens/{jti}") +async def delete_user_token( + jti: str, + request: Request, + current_user=Depends(require_auth), +): + """Revoke + delete an API/MCP token (immediate effect on API and MCP).""" + try: + record = delete_api_token(jti, current_user["username"]) + except KeyError: + raise HTTPException(404, "Token introuvable") + from backend.audit import log_config_change + log_config_change(current_user["username"], + {"action": "api_token_revoke", "name": record["name"]}, + ip=get_client_ip(request)) + return {"message": f"Token '{record['name']}' révoqué"} diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 633b788..1522402 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.14.1" +version = "2.15.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 5bf1ac2..89ccd3b 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.14.1" +version = "2.15.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index ebbb0ce..8e37def 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.14.1", + "version": "2.15.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fb09d48..a58a659 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.14.1 | **Dernière mise à jour :** 2026-09-22 +> **Version :** 2.15.0 | **Dernière mise à jour :** 2026-09-22 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -189,6 +189,7 @@ | 104 | Configuration — Redesign UI de la section « Clés API IA » : recherche fournisseurs, carte défaut 2 colonnes + badges de capacités, cartes dépliables, footer d'actions sticky | 2.12.0 | [features/ai-keys-ui.md](./features/ai-keys-ui.md) | | 105 | Guide d'utilisation — audit de couverture complet, téléchargement Markdown/PDF, guide desktop élargi, section Architecture (Mermaid) + BUG-067 | 2.13.0 | [features/guide-coverage-105.md](./features/guide-coverage-105.md) | | 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) | +| 107 | Configuration — Gestion des clés API & MCP : création/révocation de jetons longue durée (1 j, 1 mois, 6 mois, 1 an, sans fin), une seule clé pour l'API REST et le serveur MCP, « dernière utilisation », store `data/api_tokens.json` sans secret persisté | 2.15.0 | [features/api-mcp-tokens-107.md](./features/api-mcp-tokens-107.md) | --- @@ -196,7 +197,7 @@ | Priorité | Items | Effort total estimé | |---|---|---| -| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–106, #92 | ~115 jours réalisés | +| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–107, #92 | ~116 jours réalisés | | 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour | | ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours | diff --git a/docs/features/api-mcp-tokens-107.md b/docs/features/api-mcp-tokens-107.md new file mode 100644 index 0000000..f08bafe --- /dev/null +++ b/docs/features/api-mcp-tokens-107.md @@ -0,0 +1,95 @@ +# #107 — Clés API & MCP (panneau de configuration) + +**Version : 2.15.0 — statut : complété (septembre 2026)** + +## Problème + +Pour brancher un client MCP externe (Claude Desktop, Cursor…) ou scripter +l'API REST, il fallait soit se connecter et voler le JWT de session de 1 h +dans le navigateur, soit générer un token à la main via `docker exec` +(`generer_access_token.sh`) — sans expiration maîtrisable ni révocation. + +## Décision : une seule clé pour l'API ET le MCP + +Le serveur MCP (`/mcp`, `backend/mcp/server.py::_authenticate`) résout +l'appelant via la même dépendance `get_current_user()` que l'API REST. +Un jeton HS256 `type=access` authentifie donc **les deux surfaces** — il +n'y a pas de famille de clés séparée à exposer dans l'UI. C'est dit +explicitement dans la section (« la même clé fonctionne pour les deux ») +et verrouillé par tests (REST 200 + MCP initialize 200 avec la même clé ; +révocation → 401 des deux côtés). + +## Conception + +### Store — `data/api_tokens.json` + +```json +{"version": 1, "tokens": {"": { + "name": "Claude Desktop", "username": "admin", + "created_at": 1790000000, "expires_at": 1792592000, + "expiry_key": "30d", "last_used_at": null +}}} +``` + +Le JWT brut n'est **jamais persisté** : affiché une seule fois à la +création, sinon perdu (pattern GitHub). La révocation fonctionne par +`jti` : le JWT présenté est rejeté par `is_token_revoked` même s'il est +encore valide dans sa signature. + +### Expirations (choix UI) + +| Clé | Durée | `exp` dans le JWT | +|---|---|---| +| `1d` | 1 jour | iat + 86 400 | +| `30d` | 1 mois | iat + 2 592 000 | +| `180d` | 6 mois | iat + 15 552 000 | +| `365d` | 1 an | iat + 31 536 000 | +| `never` | sans fin | **aucun claim exp** | + +Plafond : 50 tokens actifs par utilisateur (`API_TOKEN_MAX_PER_USER`). + +### Correction induite — révocation longue durée + +L'ancien `revoked_tokens.json` bornait toute entrée à 7 jours ; une clé +1 an révoquée aurait « repris vie » au nettoyage suivant. Le store devient +un dict `{jti: valid_until}` calé sur l'expiration réelle du jeton +(« sans fin » → 100 ans). Session tokens inchangés (7 j). + +### « Dernière utilisation » + +`get_current_user` appelle `maybe_touch_api_token(jti)` pour les jetons +`api: true` — écriture disque throttlée à 1 h par jti, silencieuse si le +dossier est en lecture seule ; ne fait jamais échouer une requête. + +## Endpoints (`/api/auth`, auth requise, périmètre = l'appelant) + +- `GET /api/auth/tokens` → `{tokens: [...], expiry_choices: [...]}` +- `POST /api/auth/tokens` `{name, expiry}` → `{token, ...record}` (secret unique) +- `DELETE /api/auth/tokens/{jti}` → révocation immédiate API + MCP +- Audit : `config_change` / `api_token_create|revoke`. + +## UI — panneau Configuration + +Nouvelle section `#cfg-tokens` « 🔑 Clés API & MCP » (après Sécurité) : +liste (badge Active/Expirée, créée/expire/dernière utilisation), champ +nom + sélecteur d'expiration + Créer, zone secrète en tirets avec Copier, +bloc « Utilisation » : header `Authorization: Bearer ` + exemple de +config MCP avec headers sur `/mcp`. 28 clés i18n FR/EN, SW v24. + +## Tests — `tests/test_api_tokens.py` (13) + +Création/liste (secret jamais restitué), les 5 durées dont l'absence +d'`exp` pour `never`, expiration invalide → 400, clé identique acceptée +par REST **et** `/mcp`, refus MCP anonyme, isolation par utilisateur, +révocation → 401 immédiat des deux côtés, survie de la révocation +longue durée après reload disque, drapeau `expired`, migration de format +`revoked_tokens.json`. Suite auth + MCP complète verte (77). + +## Config MCP externe (exemple) + +```json +{"mcpServers": {"obsigate": { + "url": "http://localhost:2020/mcp", + "headers": {"Authorization": "***"} +}}} +``` diff --git a/frontend/index.html b/frontend/index.html index 77f76bd..2a71f2c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1547,6 +1547,7 @@
  • +
  • 🔑 Clés API & MCP
  • Notifications push
  • 🧩 Plugins
  • @@ -2335,6 +2336,46 @@ + +
    +

    🔑 Clés API & MCP

    +

    + Jetons longue durée pour l'API REST et le serveur MCP. + La même clé fonctionne pour les deux (en-tête Authorization: Bearer). +

    +
    +
    + + + +
    + +
    + Utilisation + : en-tête `Authorization: Bearer ` sur l'API, et config MCP (`claude_desktop_config.json`) : `{"mcpServers":{"obsigate":{"url":"/mcp","headers":{"Authorization":"Bearer "}}}}`. +
    +
    +
    "; + } +} + +function _formatTokenDate(unixSec) { + if (!unixSec) return ""; + return new Date(unixSec * 1000).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); +} + +function _tokenExpiryLabel(tok) { + if (!tok.expires_at) return t("config.token_expiry_never"); + const map = { "1d": "config.token_expiry_1d", "30d": "config.token_expiry_30d", "180d": "config.token_expiry_180d", "365d": "config.token_expiry_365d" }; + return map[tok.expiry_key] ? t(map[tok.expiry_key]) : _formatTokenDate(tok.expires_at); +} + +function renderTokensUI(tokens) { + const list = document.getElementById("tokens-list"); + if (!list) return; + if (!tokens.length) { + list.innerHTML = '
    ' + escapeHtml(t("config.tokens_empty")) + "
    "; + return; + } + list.innerHTML = tokens.map(tok => { + const expired = tok.expired || (tok.expires_at && tok.expires_at * 1000 < Date.now()); + const status = expired + ? '' + escapeHtml(t("config.token_status_expired")) + "" + : '' + escapeHtml(t("config.token_status_active")) + ""; + const meta = [ + t("config.token_created") + " " + _formatTokenDate(tok.created_at), + t("config.token_expires") + " " + _tokenExpiryLabel(tok), + tok.last_used_at ? t("config.token_last_used") + " " + _formatTokenDate(tok.last_used_at) : t("config.token_never_used") + ].join(" · "); + return '
    ' + + '' + escapeHtml(tok.name) + "" + + status + + '' + escapeHtml(meta) + "" + + '' + + "
    "; + }).join(""); + list.querySelectorAll(".token-delete").forEach(btn => btn.addEventListener("click", async () => { + const name = btn.dataset.name; + if (!confirm(t("config.token_revoke_confirm") + " \"" + name + "\" ?")) return; + try { + await api("/api/auth/tokens/" + btn.dataset.jti, { method: "DELETE" }); + showToast(t("config.token_revoked_toast"), "success"); + loadTokensUI(); + } catch (err) { + showToast(err.message || t("config.error_unknown"), "error"); + } + })); +} + +function bindTokenEvents() { + if (_tokensBound) return; + _tokensBound = true; + const createBtn = document.getElementById("token-create-btn"); + if (!createBtn) return; + createBtn.addEventListener("click", async () => { + const name = document.getElementById("token-name-input").value.trim(); + const expiry = document.getElementById("token-expiry-select").value; + if (!name) { showToast(t("config.token_name_required"), "error"); return; } + createBtn.disabled = true; + try { + const res = await api("/api/auth/tokens", { method: "POST", body: JSON.stringify({ name, expiry }) }); + const area = document.getElementById("token-secret-area"); + const ta = document.getElementById("token-secret-value"); + ta.value = res.token; + area.classList.remove("hidden"); + ta.select(); + document.getElementById("token-name-input").value = ""; + showToast(t("config.token_created_toast"), "success"); + loadTokensUI(); + } catch (err) { + showToast(err.message || t("config.error_unknown"), "error"); + } finally { + createBtn.disabled = false; + } + }); + const copyBtn = document.getElementById("token-copy-btn"); + if (copyBtn) copyBtn.addEventListener("click", async () => { + const ta = document.getElementById("token-secret-value"); + try { await navigator.clipboard.writeText(ta.value); } + catch { ta.select(); document.execCommand("copy"); } + showToast(t("config.token_copied"), "success"); + }); + const dismissBtn = document.getElementById("token-dismiss-btn"); + if (dismissBtn) dismissBtn.addEventListener("click", () => { + document.getElementById("token-secret-area").classList.add("hidden"); + document.getElementById("token-secret-value").value = ""; + }); +} + // ── Shares UI ── async function loadSharesUI() { const list = document.getElementById("shares-list"); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 3b00f8b..3500653 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -572,6 +572,34 @@ "config.title_boost": "Title boost", "config.title_boost_hint": "Relevance multiplier for title matches", "config.title_boost_label": "Title boost", + "config.nav_tokens": "🔑 API & MCP keys", + "config.section_tokens": "🔑 API & MCP keys", + "config.tokens_desc": "Long-lived tokens for the REST API and the MCP server — the same key works for both (Authorization: Bearer header).", + "config.tokens_empty": "No API keys created.", + "config.token_name_placeholder": "Name (e.g. Claude Desktop)", + "config.token_name_required": "A name is required", + "config.token_expiry_1d": "1 day", + "config.token_expiry_30d": "1 month", + "config.token_expiry_180d": "6 months", + "config.token_expiry_365d": "1 year", + "config.token_expiry_never": "Never", + "config.token_create": "Create key", + "config.token_secret_warning": "Copy this key now — it will never be shown again.", + "config.token_copy": "Copy", + "config.token_copied": "Key copied to clipboard", + "config.token_done": "Done", + "config.token_usage": "Usage", + "config.token_usage_detail": ": \"Authorization: Bearer \" header on the API; for MCP, declare it in the headers of the /mcp URL.", + "config.token_created": "Created", + "config.token_expires": "Expires", + "config.token_last_used": "Last used", + "config.token_never_used": "Never used", + "config.token_status_active": "Active", + "config.token_status_expired": "Expired", + "config.token_revoke": "Revoke", + "config.token_revoke_confirm": "Revoke key", + "config.token_revoked_toast": "Key revoked (immediate effect on API + MCP)", + "config.token_created_toast": "Key created", "config.url_required": "URL required", "config.watcher_debounce_label": "Debounce (s)", "config.watcher_enabled_label": "Enable watcher", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 781b8ed..08fa546 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -572,6 +572,34 @@ "config.title_boost": "Boost titre", "config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre", "config.title_boost_label": "Boost titre", + "config.nav_tokens": "🔑 Clés API & MCP", + "config.section_tokens": "🔑 Clés API & MCP", + "config.tokens_desc": "Jetons longue durée pour l'API REST et le serveur MCP — la même clé fonctionne pour les deux (en-tête Authorization: Bearer).", + "config.tokens_empty": "Aucune clé API créée.", + "config.token_name_placeholder": "Nom (ex: Claude Desktop)", + "config.token_name_required": "Un nom est requis", + "config.token_expiry_1d": "1 jour", + "config.token_expiry_30d": "1 mois", + "config.token_expiry_180d": "6 mois", + "config.token_expiry_365d": "1 an", + "config.token_expiry_never": "Sans fin", + "config.token_create": "Créer une clé", + "config.token_secret_warning": "Copiez cette clé maintenant — elle ne sera plus jamais affichée.", + "config.token_copy": "Copier", + "config.token_copied": "Clé copiée dans le presse-papiers", + "config.token_done": "Terminé", + "config.token_usage": "Utilisation", + "config.token_usage_detail": ": en-tête « Authorization: Bearer » sur l'API ; pour MCP, déclarez-la dans les headers de l'URL /mcp.", + "config.token_created": "Créée le", + "config.token_expires": "Expire", + "config.token_last_used": "Dernière utilisation", + "config.token_never_used": "Jamais utilisée", + "config.token_status_active": "Active", + "config.token_status_expired": "Expirée", + "config.token_revoke": "Révoquer", + "config.token_revoke_confirm": "Révoquer la clé", + "config.token_revoked_toast": "Clé révoquée (effet immédiat API + MCP)", + "config.token_created_toast": "Clé créée", "config.url_required": "URL requise", "config.watcher_debounce_label": "Debounce (s)", "config.watcher_enabled_label": "Activer la surveillance", diff --git a/frontend/style.css b/frontend/style.css index e7b230e..d3b64aa 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -7607,6 +7607,27 @@ body.popup-mode .content-area { .config-add-row { display: flex; gap: 8px; margin-top: 8px; } .config-btn-add { padding: 6px 14px; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem; } +/* ── API/MCP tokens UI (#107) ── */ +.token-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: var(--bg-card, var(--bg-secondary)); + border-radius: 6px; + margin-bottom: 6px; + font-size: 0.8rem; +} +.token-name { font-weight: 500; min-width: 90px; } +.token-badge { font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; white-space: nowrap; } +.token-badge-active { background: color-mix(in srgb, var(--success, #2e7d32) 18%, transparent); color: var(--success, #2e7d32); } +.token-badge-expired { background: color-mix(in srgb, var(--text-error, #c62828) 15%, transparent); color: var(--text-error, #c62828); } +.token-meta { color: var(--text-muted); font-size: 0.7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } +.token-delete { background: none; border: none; color: var(--text-error); cursor: pointer; font-size: 1rem; padding: 2px 6px; } +.token-secret-area { margin-top: 10px; padding: 10px; border: 1px dashed var(--accent); border-radius: 8px; } +.token-secret-area.hidden { display: none; } +.token-secret-text { width: 100%; font-family: monospace; font-size: 0.7rem; word-break: break-all; resize: none; } + /* ── Shares UI ── */ .share-item { display: flex; diff --git a/frontend/sw.js b/frontend/sw.js index 8131501..493fd36 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -11,7 +11,7 @@ * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * a separate store. Bumping SW_VERSION invalidates it on every release. */ -const SW_VERSION = 'v23'; +const SW_VERSION = 'v24'; const CODE_CACHE = `obsigate-code-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`; diff --git a/generer_access_token.sh b/generer_access_token.sh new file mode 100644 index 0000000..1ceee12 --- /dev/null +++ b/generer_access_token.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Génère un token d'accès ObsiGate longue durée pour le client MCP +# Conteneur de test local: obsigate-test (adapter le nom selon l'instance) +docker exec -i obsigate-test python - <<'PYEOF' +import time, uuid, json +from jose import jwt +key = open("data/secret.key").read().strip() +u = json.load(open("data/users.json"))["users"]["admin"] +now = int(time.time()) +tok = jwt.encode({ + "sub": "admin", + "role": u["role"], + "vaults": u["vaults"], + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + 31536000, # 1 an + "type": "access", +}, key, algorithm="HS256") +print(tok) +PYEOF diff --git a/package.json b/package.json index 0d1170f..9783b96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.14.1", + "version": "2.15.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py new file mode 100644 index 0000000..40042a1 --- /dev/null +++ b/tests/test_api_tokens.py @@ -0,0 +1,271 @@ +# tests/test_api_tokens.py — Feature #107 : jetons API/MCP gérés dans la config. +"""Couvre : +- création / liste / révocation via /api/auth/tokens ; +- le même jeton authentifie l'API REST ET le serveur MCP /mcp ; +- choix d'expiration 1d/30d/180d/365d/never (revoked_tokens et exp) ; +- révocation immédiate et persistante (pas de retour à la vie après 7 jours) ; +- isolation par utilisateur, auth requise. +""" +import json +import os +import time + +import pytest +from fastapi.testclient import TestClient + +ACCEPT = "application/json, text/event-stream" + + +@pytest.fixture +def tokens_client(tmp_path, monkeypatch): + """Auth-enabled TestClient in an isolated data dir (admin / chab30).""" + import shutil + from pathlib import Path + + data_dir = tmp_path / "data" + data_dir.mkdir() + from backend.auth.password import hash_password + + users = { + "version": 1, + "users": { + "admin": { + "id": "admin-1", "username": "admin", "display_name": "admin", + "password_hash": hash_password("chab30"), "role": "admin", + "vaults": ["*"], "active": True, + "created_at": "2026-01-01T00:00:00", + }, + "bob": { + "id": "bob-1", "username": "bob", "display_name": "bob", + "password_hash": hash_password("chab30"), "role": "user", + "vaults": ["TestVault"], "active": True, + "created_at": "2026-01-01T00:00:00", + }, + }, + } + (data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8") + src_secret = Path("data/secret.key") + if src_secret.exists(): + shutil.copy2(str(src_secret), str(data_dir / "secret.key")) + + vault = os.path.abspath("test_vault") + orig_cwd = os.getcwd() + os.chdir(str(tmp_path)) + os.environ["VAULT_1_NAME"] = "TestVault" + os.environ["VAULT_1_PATH"] = vault + os.environ["OBSIGATE_AUTH_ENABLED"] = "true" + os.environ["OBSIGATE_WATCHER_ENABLED"] = "false" + + import backend.main + backend.main._load_config = lambda: {"watcher_enabled": False} + + from backend.indexer import build_index, index + import asyncio + for key in list(index.keys()): + del index[key] + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(build_index()) + from backend.search import init_inverted_index + init_inverted_index() + + # Fresh revoked-token state per test (module caches a global map). + import backend.auth.jwt_handler as jh + jh._revoked_jtis_backup = getattr(jh, "_revoked_map", {}) + jh._revoked_map = {} + jh._revoked_loaded = False + jh._touch_last_write.clear() + + # The MCP session manager can only run() once per instance/event-loop + # (same reset as tests/test_mcp.py::mcp_client) — otherwise this file's + # /mcp tests 500 when an earlier test already bound it to a dead loop. + backend.main.mcp_app._manager = None + backend.main.mcp_app._run_task = None + backend.main.mcp_app._start_lock = None + + with TestClient(backend.main.app) as client: + yield client + + backend.main.mcp_app._manager = None + backend.main.mcp_app._run_task = None + backend.main.mcp_app._start_lock = None + jh._revoked_map = {} + jh._revoked_loaded = False + os.chdir(orig_cwd) + shutil.rmtree(str(tmp_path), ignore_errors=True) + for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED", + "OBSIGATE_WATCHER_ENABLED"]: + os.environ.pop(k, None) + + +_TEST_PW = "chab" + "30" + + +def _login(client, username="admin", password=_TEST_PW): + resp = client.post("/api/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200, resp.text + return resp.json()["access_token"] + + +def _hdr(token): + return {"Authorization": f"Bearer {token}"} + + +def _create(client, token, name="claude desktop", expiry="30d"): + resp = client.post("/api/auth/tokens", json={"name": name, "expiry": expiry}, + headers=_hdr(token)) + assert resp.status_code == 200, resp.text + return resp.json() + + +# ═══════════════════════════════════════════════════════════════════ + + +class TestCreateAndList: + def test_requires_auth(self, tokens_client): + assert tokens_client.get("/api/auth/tokens").status_code == 401 + + def test_create_returns_token_once(self, tokens_client): + tok = _login(tokens_client) + created = _create(tokens_client, tok) + assert created["token"].count(".") == 2 # JWT + assert created["name"] == "claude desktop" + assert created["expires_at"] is not None + listing = tokens_client.get("/api/auth/tokens", headers=_hdr(tok)).json() + assert [t["jti"] for t in listing["tokens"]] == [created["jti"]] + # Le secret n'est JAMAIS stocké/restitués en liste. + assert "token" not in listing["tokens"][0] + + def test_expiry_choices(self, tokens_client): + tok = _login(tokens_client) + from backend.auth.jwt_handler import decode_token + expected = {"1d": 86400, "30d": 2592000, "180d": 15552000, "365d": 31536000} + for key, secs in expected.items(): + c = _create(tokens_client, tok, name=key, expiry=key) + payload = decode_token(c["token"]) + assert payload["exp"] - payload["iat"] == secs + never = _create(tokens_client, tok, name="never", expiry="never") + payload = decode_token(never["token"]) + assert "exp" not in payload and never["expires_at"] is None + + def test_invalid_expiry_rejected(self, tokens_client): + tok = _login(tokens_client) + resp = tokens_client.post("/api/auth/tokens", + json={"name": "x", "expiry": "5minutes"}, + headers=_hdr(tok)) + assert resp.status_code == 400 + + +class TestTokenWorksOnApiAndMcp: + """Le point #107 : une seule clé pour l'API REST et le serveur MCP.""" + + def test_authenticates_rest_api(self, tokens_client): + api_tok = _login(tokens_client) + key = _create(tokens_client, api_tok)["token"] + me = tokens_client.get("/api/auth/me", headers=_hdr(key)) + assert me.status_code == 200 + assert me.json()["username"] == "admin" + + def test_mcp_endpoint_rejects_anonymous(self, tokens_client): + resp = tokens_client.post( + "/mcp", + content=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {}}), + headers={"Accept": ACCEPT, "Content-Type": "application/json"}, + ) + assert resp.status_code == 401 + + def test_same_key_authenticates_mcp(self, tokens_client): + api_tok = _login(tokens_client) + key = _create(tokens_client, api_tok)["token"] + resp = tokens_client.post( + "/mcp", + content=json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "pytest", "version": "1.0"}}, + }), + headers={"Accept": ACCEPT, "Content-Type": "application/json", + "Authorization": f"Bearer {key}"}, + ) + assert resp.status_code == 200, resp.text + assert resp.headers.get("mcp-session-id") + + def test_api_token_vault_scope_from_login_snapshot(self, tokens_client): + # bob (user role, vaults=[TestVault]) creates a token; /api/auth/me ok. + bob = _login(tokens_client, "bob") + key = _create(tokens_client, bob, name="bob-key")["token"] + assert tokens_client.get("/api/auth/me", headers=_hdr(key)).status_code == 200 + # admin's listing must not see bob's token. + admin = _login(tokens_client) + names = [t["name"] for t in + tokens_client.get("/api/auth/tokens", headers=_hdr(admin)).json()["tokens"]] + assert "bob-key" not in names + + +class TestRevoke: + def test_revoke_kills_api_and_mcp_immediately(self, tokens_client): + api_tok = _login(tokens_client) + created = _create(tokens_client, api_tok) + key, jti = created["token"], created["jti"] + assert tokens_client.get("/api/auth/me", headers=_hdr(key)).status_code == 200 + resp = tokens_client.delete(f"/api/auth/tokens/{jti}", headers=_hdr(api_tok)) + assert resp.status_code == 200 + # API + assert tokens_client.get("/api/auth/me", headers=_hdr(key)).status_code == 401 + # MCP — même clé révoquée = 401 aussi + mcp = tokens_client.post( + "/mcp", + content=json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {}}), + headers={"Accept": ACCEPT, "Content-Type": "application/json", + "Authorization": f"Bearer {key}"}, + ) + assert mcp.status_code == 401 + + def test_revoke_unknown_is_404(self, tokens_client): + api_tok = _login(tokens_client) + assert tokens_client.delete("/api/auth/tokens/nope", + headers=_hdr(api_tok)).status_code == 404 + + def test_revocation_survives_7day_cleanup_for_long_lived(self, tokens_client): + """Un jeton 'never' révoqué ne doit PAS revenir à la vie : la révocation + est bornée à l'expiration du jeton lui-même (infini ici).""" + import backend.auth.jwt_handler as jh + api_tok = _login(tokens_client) + created = _create(tokens_client, api_tok, name="forever", expiry="never") + tokens_client.delete(f"/api/auth/tokens/{created['jti']}", headers=_hdr(api_tok)) + until = jh._revoked_map[created["jti"]] + # 30+ ans devant nous → survit à tout nettoyage "7 days max". + assert until > time.time() + 365 * 24 * 3600 + # Reload depuis le disque → toujours révoqué. + jh._revoked_map = {} + jh._revoked_loaded = False + assert jh.is_token_revoked(created["jti"]) is True + + def test_expired_token_flagged_in_list(self, tokens_client): + from backend.auth.jwt_handler import _load_api_tokens, _save_api_tokens + api_tok = _login(tokens_client) + created = _create(tokens_client, api_tok, name="old", expiry="1d") + # Forcer l'expiration côté registre + jeton (via iat/exp passés). + data = _load_api_tokens() + data["tokens"][created["jti"]]["expires_at"] = int(time.time()) - 10 + _save_api_tokens(data) + listing = tokens_client.get("/api/auth/tokens", headers=_hdr(api_tok)).json() + assert listing["tokens"][0]["expired"] is True + + +class TestRevokedStoreFormat: + def test_migration_from_list_format(self, tmp_path, monkeypatch): + """Ancien format (set) et nouveau (dict jti->until) coexistent au load.""" + from backend.auth.jwt_handler import ( + REVOKED_TOKENS_FILE, _load_revoked, is_token_revoked, + ) + import backend.auth.jwt_handler as jh + REVOKED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True) + future = int(time.time()) + 3600 + REVOKED_TOKENS_FILE.write_text(json.dumps( + {"alive": future, "dead": int(time.time()) - 10})) + jh._revoked_map, jh._revoked_loaded = {}, False + _load_revoked() + assert is_token_revoked("alive") and not is_token_revoked("dead")