0.25.0 — Jetons d'API personnels (kbt_) pour les surfaces machine
CI / test (push) Successful in 1m56s

Depuis 0.24.0, /mcp exige session ou Basic : le service d'agents s'y
faisait refuser en 401 (vu en production, panneau Spark du 2026-10-21).

- Table api_tokens (empreinte SHA-256 seule), CRUD dans Mon profil
- Bearer honoré par le middleware sur /mcp et /api/ uniquement —
  jamais l'interface web ; révocation et désactivation immédiates
- 235 tests verts (3 nouveaux : cycle de vie, compte désactivé,
  handshake MCP par jeton quand l'auth est active)
This commit is contained in:
Spark
2026-10-21 21:38:16 -04:00
parent 2381ae0b35
commit 13182f5d53
7 changed files with 282 additions and 7 deletions
+79
View File
@@ -11,8 +11,10 @@ and upgrades older databases in place (position column, notes
updated_at, cleanup of rows carrying an unknown column name).
"""
import hashlib
import json
import os
import secrets
import sqlite3
from contextlib import contextmanager
from typing import Iterator
@@ -134,6 +136,16 @@ def init_db() -> None:
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL DEFAULT '',
token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
@@ -900,6 +912,73 @@ def get_user_by_username(username: str) -> dict | None:
return dict(row) if row else None
# --- Jetons d'API (0.25.0) -------------------------------------------------
# Jetons personnels pour les consommateurs machine (le service d'agents
# et son client MCP, en premier lieu). Le texte clair (préfixe kbt_)
# n'existe qu'à la création ; la base ne garde que l'empreinte SHA-256.
# Un jeton authentifie uniquement les surfaces machine (/mcp, /api/) —
# jamais l'interface web.
API_TOKEN_PREFIX = "kbt_"
def _hash_api_token(plaintext: str) -> str:
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
def create_api_token(user_id: int, name: str) -> tuple[dict, str]:
plaintext = API_TOKEN_PREFIX + secrets.token_urlsafe(32)
with get_db() as conn:
cur = conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash) VALUES (?, ?, ?)",
(user_id, name.strip(), _hash_api_token(plaintext)),
)
row = conn.execute(
"SELECT * FROM api_tokens WHERE id = ?", (cur.lastrowid,)
).fetchone()
return dict(row), plaintext
def list_api_tokens(user_id: int) -> list[dict]:
with get_db() as conn:
return [
dict(r)
for r in conn.execute(
"SELECT * FROM api_tokens WHERE user_id = ? ORDER BY id",
(user_id,),
).fetchall()
]
def revoke_api_token(user_id: int, token_id: int) -> bool:
with get_db() as conn:
cur = conn.execute(
"UPDATE api_tokens SET revoked_at = datetime('now')"
" WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
(token_id, user_id),
)
return cur.rowcount > 0
def user_by_api_token(plaintext: str) -> dict | None:
if not plaintext or not plaintext.startswith(API_TOKEN_PREFIX):
return None
with get_db() as conn:
row = conn.execute(
"SELECT u.* FROM api_tokens t JOIN users u ON u.id = t.user_id"
" WHERE t.token_hash = ? AND t.revoked_at IS NULL AND u.active = 1",
(_hash_api_token(plaintext),),
).fetchone()
if row is None:
return None
conn.execute(
"UPDATE api_tokens SET last_used_at = datetime('now')"
" WHERE token_hash = ?",
(_hash_api_token(plaintext),),
)
return dict(row)
def get_user_by_draco(sub: str) -> dict | None:
with get_db() as conn:
row = conn.execute(