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
# 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
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).
> **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
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.
[![Version](https://img.shields.io/badge/Version-2.14.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.15.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -927,8 +927,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.14.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.15.0).
---
*Projet : ObsiGate | Version : 2.14.1 | Dernière mise à jour : Juin 2026*
*Projet : ObsiGate | Version : 2.15.0 | Dernière mise à jour : Juin 2026*
+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.
[![Version](https://img.shields.io/badge/Version-2.14.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.15.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1096,8 +1096,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.14.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.15.0).
---
*Project: ObsiGate | Version: 2.14.1 | Last updated: May 2026*
*Project: ObsiGate | Version: 2.15.0 | Last updated: May 2026*
+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 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}")
+5 -1
View File
@@ -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
+59
View File
@@ -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é"}
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.14.1"
version = "2.15.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 |
+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-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 &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 -->
<section
class="config-section help-section"
+104
View File
@@ -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");
+28
View File
@@ -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",
+28
View File
@@ -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",
+21
View File
@@ -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
View File
@@ -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}`;
+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",
"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": {
+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")