feat: #107 configuration - gestion des clés API & MCP (création/révocation, expiration 1j/1mois/6mois/1an/sans fin, une clé pour API REST + serveur MCP, dernière utilisation, store sans secret persisté; fix révocation longue durée) + script token MCP
CI / lint (push) Successful in 1m58s
CI / security (push) Successful in 1m32s
CI / test (push) Successful in 4m0s
CI / build (push) Successful in 1m15s
CI / e2e (push) Successful in 12m9s

This commit is contained in:
2026-09-22 14:48:51 -04:00
parent 6f0a6f7fd8
commit eff74cabe0
22 changed files with 885 additions and 33 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# OBSIGATE_SECURE_COOKIES=false # OBSIGATE_SECURE_COOKIES=false
# Tokens TTL en secondes # Tokens TTL en secondes
# OBSIGATE_ACCESS_TOKEN_TTL=900 # OBSIGATE_ACCESS_TOKEN_TTL=31536000000 # 1000 ans
# OBSIGATE_REFRESH_TOKEN_TTL=604800 # OBSIGATE_REFRESH_TOKEN_TTL=604800
# Rate limiting # Rate limiting
+22 -1
View File
@@ -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). 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 > **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 ## [2.14.1] — 2026-09-22
--- ---
+3 -3
View File
@@ -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. **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) [![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/) [![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/) [![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 ## 📝 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*
+3 -3
View File
@@ -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. **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) [![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/) [![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/) [![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 ## 📝 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*
+1 -1
View File
@@ -1 +1 @@
2.14.1 2.15.0
+175 -16
View File
@@ -7,6 +7,7 @@ import json
import logging import logging
import os import os
import secrets import secrets
import threading
import time import time
import uuid import uuid
from pathlib import Path 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 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 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) # In-memory revoked token set (loaded from disk on startup)
_revoked_jtis: set = set() _revoked_jtis: set = set()
_revoked_loaded = False _revoked_loaded = False
@@ -92,43 +109,61 @@ def decode_token(token: str) -> dict | None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Token revocation # 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(): def _load_revoked():
"""Load revoked token JTIs from disk into memory (once).""" """Load revoked token JTIs from disk into memory (once)."""
global _revoked_loaded, _revoked_jtis global _revoked_loaded, _revoked_map
if _revoked_loaded: if _revoked_loaded:
return return
if REVOKED_TOKENS_FILE.exists(): if REVOKED_TOKENS_FILE.exists():
try: try:
data = json.loads(REVOKED_TOKENS_FILE.read_text()) 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()) now = int(time.time())
_revoked_jtis = { _revoked_map = {
jti for jti, exp in data.items() jti: int(exp) for jti, exp in data.items()
if exp > now if int(exp) > now
} }
except Exception as e: except Exception as e:
logger.warning(f"Failed to load revoked tokens: {e}") logger.warning(f"Failed to load revoked tokens: {e}")
_revoked_jtis = set() _revoked_map = {}
_revoked_loaded = True _revoked_loaded = True
def _save_revoked(): 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) 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 = REVOKED_TOKENS_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(data)) tmp.write_text(json.dumps(_revoked_map))
tmp.replace(REVOKED_TOKENS_FILE) tmp.replace(REVOKED_TOKENS_FILE)
def revoke_token(jti: str): def revoke_token(jti: str, expires_at: int | None = None):
"""Add a token JTI to the revocation list.""" """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() _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() _save_revoked()
logger.debug(f"Revoked token JTI: {jti[:8]}...") logger.debug(f"Revoked token JTI: {jti[:8]}...")
@@ -136,4 +171,128 @@ def revoke_token(jti: str):
def is_token_revoked(jti: str) -> bool: def is_token_revoked(jti: str) -> bool:
"""Check if a token JTI has been revoked.""" """Check if a token JTI has been revoked."""
_load_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}")
+5 -1
View File
@@ -11,7 +11,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend.services.net import get_client_ip 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 from .user_store import get_user
logger = logging.getLogger("obsigate.auth.middleware") logger = logging.getLogger("obsigate.auth.middleware")
@@ -115,6 +115,10 @@ def get_current_user(
user["_token_vaults"] = payload.get("vaults", []) user["_token_vaults"] = payload.get("vaults", [])
# Attach the token id for per-token rate limiting (AI tool layer). # Attach the token id for per-token rate limiting (AI tool layer).
user["_token_jti"] = payload.get("jti") 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. # BUG-030: expose the real client IP to the audit log.
user["_request_ip"] = get_client_ip(request) user["_request_ip"] = get_client_ip(request)
return user return user
+59
View File
@@ -17,10 +17,14 @@ from backend.services.net import get_client_ip
from .jwt_handler import ( from .jwt_handler import (
ACCESS_TOKEN_EXPIRE_SECONDS, ACCESS_TOKEN_EXPIRE_SECONDS,
API_TOKEN_EXPIRY_CHOICES,
create_access_token, create_access_token,
create_api_token,
create_refresh_token, create_refresh_token,
decode_token, decode_token,
delete_api_token,
is_token_revoked, is_token_revoked,
list_api_tokens,
revoke_token, revoke_token,
) )
from .mfa import ( from .mfa import (
@@ -861,3 +865,58 @@ async def delete_user_endpoint(
return {"message": f"Utilisateur '{username}' supprimé"} return {"message": f"Utilisateur '{username}' supprimé"}
except ValueError as e: except ValueError as e:
raise HTTPException(404, str(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é"}
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.14.1" version = "2.15.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.14.1" version = "2.15.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.14.1", "version": "2.15.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+3 -2
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # 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 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **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) | | 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) | | 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) | | 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é | | 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 | | 🔵 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 | | ⚪ 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 | | ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours |
+95
View File
@@ -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": {"<jti>": {
"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 <clé>` + exemple de
config MCP avec headers sur `<base>/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": "***"}
}}}
```
+41
View File
@@ -1547,6 +1547,7 @@
<li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li> <li><a href="#cfg-themes" class="help-nav-link" data-i18n="settings.themes"></a></li>
<li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li> <li><a href="#cfg-profile" class="help-nav-link" data-i18n="settings.profile"></a></li>
<li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li> <li><a href="#cfg-security" class="help-nav-link" data-i18n="settings.security"></a></li>
<li><a href="#cfg-tokens" class="help-nav-link" data-i18n="config.nav_tokens">🔑 Clés API & MCP</a></li>
<li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li> <li><a href="#cfg-push" class="help-nav-link" data-i18n="config.section_push">Notifications push</a></li>
<li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li> <li><a href="#cfg-plugins" class="help-nav-link" data-i18n="config.section_plugins">🧩 Plugins</a></li>
<li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li> <li><a href="#cfg-about" class="help-nav-link" data-i18n="settings.about"></a></li>
@@ -2335,6 +2336,46 @@
</div> </div>
</section> </section>
<!-- Clés API / MCP (#107) -->
<section id="cfg-tokens" class="config-section help-section">
<h2 data-i18n="config.section_tokens">🔑 Clés API &amp; MCP</h2>
<p class="config-description" data-i18n="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).
</p>
<div id="tokens-list"></div>
<div class="config-add-row" id="tokens-create-row">
<input
type="text"
id="token-name-input"
data-i18n-placeholder="config.token_name_placeholder"
placeholder="Nom (ex: Claude Desktop)"
class="config-input"
style="width: 180px"
/>
<select id="token-expiry-select" class="config-input" style="width: 140px">
<option value="1d" data-i18n="config.token_expiry_1d">1 jour</option>
<option value="30d" selected data-i18n="config.token_expiry_30d">1 mois</option>
<option value="180d" data-i18n="config.token_expiry_180d">6 mois</option>
<option value="365d" data-i18n="config.token_expiry_365d">1 an</option>
<option value="never" data-i18n="config.token_expiry_never">Sans fin</option>
</select>
<button class="config-btn-add" id="token-create-btn" data-i18n="config.token_create">Créer une clé</button>
</div>
<div id="token-secret-area" class="token-secret-area hidden">
<p class="config-description" data-i18n="config.token_secret_warning">Copiez cette clé maintenant — elle ne sera plus jamais affichée.</p>
<textarea id="token-secret-value" class="config-input token-secret-text" rows="3" readonly></textarea>
<div class="config-add-row">
<button class="config-btn-add" id="token-copy-btn" data-i18n="config.token_copy">Copier</button>
<button class="config-btn-add" id="token-dismiss-btn" data-i18n="config.token_done">Terminé</button>
</div>
</div>
<div class="qh-tip" style="margin-top:10px">
<strong data-i18n="config.token_usage">Utilisation</strong>
<span data-i18n="config.token_usage_detail">: en-tête `Authorization: Bearer ` sur l'API, et config MCP (`claude_desktop_config.json`) : `{"mcpServers":{"obsigate":{"url":"<base>/mcp","headers":{"Authorization":"Bearer "}}}}`.</span>
</div>
</section>
<!-- Notifications push --> <!-- Notifications push -->
<section <section
class="config-section help-section" class="config-section help-section"
+104
View File
@@ -773,6 +773,7 @@ function initConfigModal() {
loadAbout(); loadAbout();
await loadHiddenFilesSettings(); await loadHiddenFilesSettings();
loadWebhooksUI(); loadWebhooksUI();
loadTokensUI();
loadSharesUI(); loadSharesUI();
loadToolKeys(); loadToolKeys();
safeCreateIcons(); safeCreateIcons();
@@ -1346,6 +1347,109 @@ document.addEventListener("click", function(e) {
} }
}); });
// ── API / MCP tokens UI (#107) ──
let _tokensBound = false;
async function loadTokensUI() {
const list = document.getElementById("tokens-list");
if (!list) return;
try {
const data = await api("/api/auth/tokens");
renderTokensUI(data.tokens || []);
bindTokenEvents();
} catch (err) {
list.innerHTML = '<div class="config-description">' + escapeHtml(t("config.error_prefix") + ": " + (err.message || "")) + "</div>";
}
}
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 = '<div class="config-description">' + escapeHtml(t("config.tokens_empty")) + "</div>";
return;
}
list.innerHTML = tokens.map(tok => {
const expired = tok.expired || (tok.expires_at && tok.expires_at * 1000 < Date.now());
const status = expired
? '<span class="token-badge token-badge-expired">' + escapeHtml(t("config.token_status_expired")) + "</span>"
: '<span class="token-badge token-badge-active">' + escapeHtml(t("config.token_status_active")) + "</span>";
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 '<div class="token-item" data-jti="' + escapeHtml(tok.jti) + '">' +
'<span class="token-name">' + escapeHtml(tok.name) + "</span>" +
status +
'<span class="token-meta">' + escapeHtml(meta) + "</span>" +
'<button class="token-delete" data-jti="' + escapeHtml(tok.jti) + '" data-name="' + escapeHtml(tok.name) + '" title="' + escapeHtml(t("config.token_revoke")) + '">✕</button>' +
"</div>";
}).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 ── // ── Shares UI ──
async function loadSharesUI() { async function loadSharesUI() {
const list = document.getElementById("shares-list"); const list = document.getElementById("shares-list");
+28
View File
@@ -572,6 +572,34 @@
"config.title_boost": "Title boost", "config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches", "config.title_boost_hint": "Relevance multiplier for title matches",
"config.title_boost_label": "Title boost", "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 <key>\" 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.url_required": "URL required",
"config.watcher_debounce_label": "Debounce (s)", "config.watcher_debounce_label": "Debounce (s)",
"config.watcher_enabled_label": "Enable watcher", "config.watcher_enabled_label": "Enable watcher",
+28
View File
@@ -572,6 +572,34 @@
"config.title_boost": "Boost titre", "config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre", "config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
"config.title_boost_label": "Boost 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 <clé> » 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.url_required": "URL requise",
"config.watcher_debounce_label": "Debounce (s)", "config.watcher_debounce_label": "Debounce (s)",
"config.watcher_enabled_label": "Activer la surveillance", "config.watcher_enabled_label": "Activer la surveillance",
+21
View File
@@ -7607,6 +7607,27 @@ body.popup-mode .content-area {
.config-add-row { display: flex; gap: 8px; margin-top: 8px; } .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; } .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 ── */ /* ── Shares UI ── */
.share-item { .share-item {
display: flex; display: flex;
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is * cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release. * 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 CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`; const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`; const API_CACHE = `obsigate-api-${SW_VERSION}`;
+20
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "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.", "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", "main": "patch.js",
"directories": { "directories": {
+271
View File
@@ -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")