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
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
+22
-1
@@ -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
|
||||
|
||||
---
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
+175
-16
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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é"}
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.14.1"
|
||||
version = "2.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
|
||||
@@ -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": "***"}
|
||||
}}}
|
||||
```
|
||||
@@ -1547,6 +1547,7 @@
|
||||
<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-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-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>
|
||||
@@ -2335,6 +2336,46 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Clés API / MCP (#107) -->
|
||||
<section id="cfg-tokens" class="config-section help-section">
|
||||
<h2 data-i18n="config.section_tokens">🔑 Clés API & 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 -->
|
||||
<section
|
||||
class="config-section help-section"
|
||||
|
||||
@@ -773,6 +773,7 @@ function initConfigModal() {
|
||||
loadAbout();
|
||||
await loadHiddenFilesSettings();
|
||||
loadWebhooksUI();
|
||||
loadTokensUI();
|
||||
loadSharesUI();
|
||||
loadToolKeys();
|
||||
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 ──
|
||||
async function loadSharesUI() {
|
||||
const list = document.getElementById("shares-list");
|
||||
|
||||
@@ -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 <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.watcher_debounce_label": "Debounce (s)",
|
||||
"config.watcher_enabled_label": "Enable watcher",
|
||||
|
||||
@@ -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 <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.watcher_debounce_label": "Debounce (s)",
|
||||
"config.watcher_enabled_label": "Activer la surveillance",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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}`;
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user