Paramètres : accès API et MCP (jetons) ; documentation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nh6YQSjM9ni5jiTc8u5Tj1
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-14 10:14:31 +02:00
1 parent 9b8f53d0d0
commit 6ac2dbd227
7 files changed
+251 -1

No files matched your search

+3
View File
@@ -22,4 +22,7 @@ HISTORY_KEEP=50
HISTORY_INTERVAL=10m
HISTORY_MAX_AGE=0
SHARE_SECRET=
# Serveur MCP (/mcp) : URL publique et hôtes de retour OAuth autorisés
PUBLIC_URL=
OAUTH_REDIRECT_HOSTS=claude.ai,claude.com
EXCALIDRAW_VERSION=
+47 -1
View File
@@ -1,6 +1,6 @@
# excalidraw-go
Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), **recherche plein texte** (noms et contenu des schémas), **tags**, **espace personnel et favoris par utilisateur**, **partage en lecture seule par lien signé**, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur, **historique des versions** avec comparaison visuelle et restauration. Interface web unique, responsive (l'explorateur devient un tiroir sur mobile).
Excalidraw auto-hébergé avec un **gestionnaire de fichiers intégré** : arborescence de dossiers, **vignettes des schémas**, création, renommage, **sélection et déplacement multiples** (glisser-déposer ou dialogue « Déplacer vers… »), duplication, suppression (corbeille), **recherche plein texte** (noms et contenu des schémas), **tags**, **espace personnel et favoris par utilisateur**, **partage en lecture seule par lien signé**, import/export de fichiers `.excalidraw`, sauvegarde automatique côté serveur, **historique des versions** avec comparaison visuelle et restauration, **serveur MCP** pour que Claude lise et crée des schémas (jetons d'API personnels et OAuth). Interface web unique, responsive (l'explorateur devient un tiroir sur mobile).
- **Backend** : Go 1.24, bibliothèque standard uniquement (aucune dépendance externe), API REST/JSON, stockage en fichiers `.excalidraw` sur un volume.
- **Frontend** : React 19 + composant officiel [`@excalidraw/excalidraw`](https://www.npmjs.com/package/@excalidraw/excalidraw) 0.18.1 (licence MIT), build Vite **embarqué dans le binaire Go** (`embed`).
@@ -14,6 +14,8 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
├─ /api/... API fichiers (Go net/http)
├─ /s/<jeton> schéma partagé en lecture seule (hors authentification)
├─ /auth/... connexion OIDC (AUTH_MODE=oidc)
├─ /mcp serveur MCP (jeton Bearer requis)
├─ /oauth/... autorisation OAuth des clients MCP (claude.ai, Claude Desktop)
└─ /healthz sonde
│
▼
@@ -27,6 +29,7 @@ Navigateur ──HTTP──▶ conteneur excalidraw-go (:8080, uid 65532, rootfs
├─ .users/<id>/favoris/x.excalidraw → ../../../projets/x.excalidraw ← favoris (liens symboliques)
├─ .shares.json, .share-secret ← liens de partage et clé de signature
├─ .session-secret ← clé des sessions OIDC (si SESSION_SECRET vide)
├─ .tokens.json ← jetons d'API et autorisations OAuth (hachés SHA-256), clé HMAC
├─ .history/projets/réseau/sdn.excalidraw/<date>.excalidraw ← versions
└─ .trash/<horodatage>/... ← éléments supprimés
```
@@ -220,6 +223,38 @@ L'historique suit les renommages et déplacements ; à la suppression d'un sché
Chaque lecture renvoie un `ETag` (SHA-256 tronqué du contenu) ; chaque sauvegarde l'envoie en `If-Match`. Si le fichier a été modifié ailleurs (autre onglet, autre poste), le serveur répond `412` et l'interface propose d'écraser ou de recharger. Il n'y a pas d'édition collaborative temps réel (voir *Évolutions*).
## Accès MCP (Claude) et jetons d'API
Le serveur expose un [serveur MCP](https://modelcontextprotocol.io) sur `https://<hôte>/mcp` (transport *Streamable HTTP*, réponses JSON) : Claude peut lister, rechercher, lire, créer ou remplacer des schémas et créer des dossiers, avec les droits de l'utilisateur qui l'a autorisé (arborescence commune, son `.perso`, ses favoris).
| Outil | Rôle |
|---|---|
| `list_diagrams` | contenu d'un dossier (`recursive` : sous-dossiers, 500 entrées max) |
| `search_diagrams` | recherche plein texte (noms, tags, texte), filtres tag et favoris |
| `read_diagram` | éléments et `appState` d'un schéma (images embarquées omises par défaut) |
| `write_diagram` | création, ou remplacement avec `overwrite: true` (version précédente conservée dans l'historique) ; renvoie le lien du schéma |
| `create_folder` | création d'un dossier (et de ses parents) |
`write_diagram` complète les éléments incomplets (id, seed, couleurs…) et accepte deux raccourcis de l'API *skeleton* d'Excalidraw : `"label": {"text": "…"}` sur une forme ou une flèche (texte lié, forme agrandie si le texte ne tient pas), et `"start"/"end": {"id": "…"}` sur une flèche (liaison aux formes, tracé calculé de bord à bord). Aucun outil ne supprime ni ne déplace.
Chaque requête vers `/mcp` doit porter `Authorization: Bearer <jeton>`. Deux façons d'obtenir un jeton, gérées dans **Paramètres › Accès API et MCP** (liste, date de dernière utilisation, révocation) :
- **Connecteur claude.ai, Claude Desktop, Claude mobile** : ces clients ne savent pas envoyer de jeton fixe (sauf en-têtes de requête, en bêta pour certaines organisations) ; ils utilisent OAuth. excalicloud embarque un petit serveur d'autorisation OAuth 2.1 (enregistrement dynamique RFC 7591, PKCE S256, rotation des jetons de rafraîchissement) adossé à la connexion existante (OIDC ou Basic) : *Paramètres › Connecteurs › Ajouter un connecteur personnalisé*, URL `https://<hôte>/mcp`, champs OAuth vides. Claude ouvre la page de connexion d'excalicloud puis une page de consentement ; l'accès apparaît ensuite dans la liste (« Connecteur autorisé (claude.ai) »). Jeton d'accès valable 1 h, autorisation expirée après 90 jours sans usage.
- **Claude Code, scripts** : créer un **jeton personnel** (affiché une seule fois, sans expiration, révocable), puis :
```bash
claude mcp add --transport http excalicloud https://<hôte>/mcp \
--header "Authorization: Bearer exc_…"
```
Les jetons personnels valent aussi pour l'API REST (`curl -H "Authorization: Bearer exc_…" https://<hôte>/api/list`), sauf la gestion des jetons, réservée à la session de l'interface. Claude Code peut aussi passer par OAuth (`claude mcp add --transport http excalicloud https://<hôte>/mcp` sans en-tête, puis `/mcp` pour s'authentifier).
Conditions côté exploitation :
- `PUBLIC_URL` (ou à défaut l'origine de `OIDC_REDIRECT_URL`) doit être l'URL publique exacte : elle forme l'identifiant de ressource OAuth `<PUBLIC_URL>/mcp`. Vide, elle est déduite de `X-Forwarded-Proto`/`X-Forwarded-Host`.
- Les connecteurs claude.ai/Desktop appellent le serveur **depuis l'infrastructure d'Anthropic** (plage `160.79.104.0/21`) : `/mcp`, `/.well-known/oauth-*` et `/oauth/*` doivent être joignables depuis Internet et, derrière un SSO porté par le proxy, exclus de son authentification (`/oauth/authorize` reste protégé par la session d'excalicloud).
- `OAUTH_REDIRECT_HOSTS` (défaut `claude.ai,claude.com`) limite les adresses de retour OAuth en https ; les adresses de bouclage (`http://localhost:<port>`, Claude Code) sont toujours acceptées.
## API
Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants commençant par `.` sont refusés, et les liens symboliques sortant de la racine sont bloqués. Deux préfixes virtuels, propres à l'utilisateur de la requête : `.perso/…` (son espace personnel) et `.favoris` (`GET /api/list?path=.favoris` liste ses favoris, `path` = chemin de l'original, `link` = nom du lien ; `DELETE /api/entry?path=.favoris/<lien>` retire le lien). La racine (`path=`) commence par les entrées `{"special":"favoris"}` et `{"special":"perso"}`.
@@ -256,6 +291,13 @@ Tous les chemins sont relatifs à `DATA_DIR`, en notation `/`. Les composants co
| PUT | `/api/logo` | PNG (5 Mo, 8192×8192 px max) | réglages ; image recadrée et ré-encodée |
| DELETE | `/api/logo` | | réglages |
| GET | `/api/update` | | `{"enabled","tags":{"latest","next"},"latest","checkedAt"}` |
| GET | `/api/tokens` | | `[{"id","name","kind":"api"\|"oauth","client","created","lastUsed"}]` : jetons de l'utilisateur |
| POST | `/api/tokens` | `{"name":"Claude Code"}` | `201`, jeton + `"token"` en clair (seule fois où il est renvoyé) |
| DELETE | `/api/tokens/<id>` | | `204` (jeton ou autorisation OAuth révoqués) |
| POST | `/mcp` | JSON-RPC 2.0 (MCP) + `Authorization: Bearer` | serveur MCP ; `401` + `WWW-Authenticate: Bearer resource_metadata=…` sans jeton |
| GET | `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server` | | métadonnées OAuth (RFC 9728, RFC 8414), hors authentification |
| POST | `/oauth/register`, `/oauth/token` | | enregistrement dynamique et échange de jetons, hors authentification |
| GET/POST | `/oauth/authorize` | | page de consentement (session requise) |
| GET | `/api/me` | | `{"mode":"none"\|"local"\|"oidc","user":{"sub","name","email"},"logout","id"}` ; `id` : identifiant de l'espace personnel |
| GET | `/auth/login?next=/chemin` | | mode `oidc` : redirection vers le fournisseur |
| GET | `/auth/callback` | | mode `oidc` : retour du fournisseur, ouverture de session |
@@ -293,6 +335,8 @@ internal/auth/ OIDC (PKCE, vérification de l'ID Token, JWKS) et se
internal/storage/ accès disque : sécurité des chemins, écriture atomique, ETag, corbeille, historique
internal/storage/spaces.go espaces personnels (.perso) et favoris par utilisateur (.favoris, liens symboliques)
internal/api/ routes REST
internal/tokens/ jetons d'API (hachés), authentification Bearer, serveur d'autorisation OAuth 2.1
internal/mcp/ serveur MCP (JSON-RPC, outils, normalisation des éléments Excalidraw)
web/embed.go embarque web/dist (produit par le build Vite)
frontend/src/App.tsx éditeur Excalidraw, sauvegarde auto, conflits, bibliothèque
frontend/src/Explorer.tsx gestionnaire de fichiers (vignettes/liste, sélection multiple, glisser-déposer)
@@ -304,6 +348,7 @@ frontend/src/TagsDialog.tsx tags d'un ou plusieurs éléments
frontend/src/ShareDialog.tsx liens de partage d'un schéma
frontend/src/ShareView.tsx vue publique /s/<jeton> (lecture seule)
frontend/src/UserMenu.tsx cartouche oidc/local + identifiant, déconnexion (mode OIDC)
frontend/src/ApiAccess.tsx paramètres : adresse MCP, jetons personnels et connecteurs autorisés
```
## Sécurité
@@ -312,6 +357,7 @@ frontend/src/UserMenu.tsx cartouche oidc/local + identifiant, déconnexion (mo
- L'authentification Basic (`AUTH_MODE=local`) suffit pour un usage local ; en exposition réseau, utiliser `AUTH_MODE=oidc` derrière un reverse proxy TLS (Traefik, Caddy, nginx), ou un SSO porté par le proxy (Authelia, oauth2-proxy, Authentik outpost) avec `AUTH_MODE=local` et `AUTH_*` vides.
- Écritures atomiques (fichier temporaire + `fsync` + `rename`) : pas de schéma tronqué en cas d'arrêt brutal.
- Liens de partage : seule surface accessible sans authentification ; jetons non énumérables (id 128 bits signé HMAC), révocables, à durée limitée au choix. Un lien donne la dernière version enregistrée du schéma, images embarquées comprises.
- Jetons d'API : 256 bits aléatoires, conservés hachés (SHA-256) dans `.tokens.json` (0600), rattachés à un utilisateur, révocables ; un jeton ne peut pas gérer les jetons. OAuth : client public avec PKCE S256 obligatoire, code à usage unique (5 min), formulaire de consentement signé (HMAC, lié à l'utilisateur), redirections limitées à `OAUTH_REDIRECT_HOSTS` et au bouclage. Le serveur MCP n'offre ni suppression ni déplacement ; un remplacement laisse la version précédente dans l'historique.
- Aucune suppression physique : la purge de `.trash/` est à la charge de l'exploitant (ex. `find /data/.trash -mindepth 1 -maxdepth 1 -mtime +30 -exec rm -rf {} +`).
## Limites et évolutions possibles
+7
View File
@@ -47,6 +47,13 @@ services:
# Clé de signature des liens de partage /s/<jeton> (16 caractères min.).
# Vide : clé aléatoire générée dans DATA_DIR/.share-secret.
SHARE_SECRET: ${SHARE_SECRET:-}
# URL publique (ex. https://excalicloud.example.org) : identifiant de la
# ressource OAuth du serveur MCP (<PUBLIC_URL>/mcp). Vide : origine de
# OIDC_REDIRECT_URL, sinon déduite des en-têtes X-Forwarded-*.
PUBLIC_URL: ${PUBLIC_URL:-}
# Hôtes autorisés pour les retours OAuth des connecteurs MCP (https) ;
# localhost (Claude Code) est toujours accepté.
OAUTH_REDIRECT_HOSTS: ${OAUTH_REDIRECT_HOSTS:-claude.ai,claude.com}
volumes:
# Volume nommé : les droits (uid 65532) sont hérités de l'image.
# Pour un bind mount (ex. ./data:/data), faire `chown -R 65532:65532 ./data`.
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useState } from "react";
import { api, type ApiToken } from "./api";
import { CopyIcon, TrashIcon } from "./icons";
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { dateStyle: "short" });
/** Section « Accès API et MCP » des paramètres : adresse du serveur MCP et jetons de l'utilisateur. */
export function ApiAccess({ onError }: { onError: (msg: string | null) => void }) {
const [tokens, setTokens] = useState<ApiToken[] | null>(null);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [created, setCreated] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const mcpUrl = `${location.origin}/mcp`;
const cmd = created
? `claude mcp add --transport http excalicloud ${mcpUrl} --header "Authorization: Bearer ${created}"`
: null;
const load = () =>
api
.tokens()
.then(setTokens)
.catch((e) => onError(e.message));
useEffect(() => {
load();
}, []);
const copy = (what: string, text: string) =>
navigator.clipboard?.writeText(text).then(() => {
setCopied(what);
setTimeout(() => setCopied(null), 1500);
});
const create = async () => {
if (!name.trim()) return;
setBusy(true);
onError(null);
try {
const t = await api.createToken(name);
setCreated(t.token);
setName("");
load();
} catch (e) {
onError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const revoke = async (t: ApiToken) => {
const what = t.kind === "oauth" ? `l'accès de « ${t.name} » (${t.client})` : `le jeton « ${t.name} »`;
if (!confirm(`Révoquer ${what} ? Les clients qui l'utilisent n'auront plus accès.`)) return;
onError(null);
try {
await api.revokeToken(t.id);
} catch (e) {
onError(e instanceof Error ? e.message : String(e));
}
load();
};
return (
<section className="field api-access">
<h4>Accès API et MCP</h4>
<span>Adresse du serveur MCP</span>
<div className="cmd">
<code>{mcpUrl}</code>
<button type="button" className="icon-btn" onClick={() => copy("url", mcpUrl)} title="Copier" aria-label="Copier l'adresse">
<CopyIcon />
</button>
</div>
<small className="muted">
{copied === "url" ? (
"Adresse copiée."
) : (
<>
claude.ai, Claude Desktop ou mobile : <i>Paramètres › Connecteurs › Ajouter un connecteur personnalisé</i>{" "}
avec cette adresse, sans identifiant OAuth ; la connexion ouvre ici une page d'autorisation. Claude Code et
scripts : créer un jeton personnel.
</>
)}
</small>
<div className="token-create">
<input
className="text-input"
value={name}
maxLength={80}
placeholder="Nom du jeton, ex. Claude Code portable"
aria-label="Nom du nouveau jeton"
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
create();
}
}}
/>
<button type="button" disabled={busy || !name.trim()} onClick={create}>
Créer un jeton
</button>
</div>
{created && cmd && (
<div className="token-new">
<small>Jeton créé : le copier maintenant, il ne sera plus affiché.</small>
<div className="cmd">
<code>{created}</code>
<button type="button" className="icon-btn" onClick={() => copy("token", created)} title="Copier" aria-label="Copier le jeton">
<CopyIcon />
</button>
</div>
<small className="muted">{copied === "token" ? "Jeton copié." : "Commande Claude Code :"}</small>
<div className="cmd">
<code>{cmd}</code>
<button type="button" className="icon-btn" onClick={() => copy("cmd", cmd)} title="Copier" aria-label="Copier la commande">
<CopyIcon />
</button>
</div>
{copied === "cmd" && <small className="muted">Commande copiée.</small>}
</div>
)}
{tokens?.length ? (
<ul className="share-list all">
{tokens.map((t) => (
<li key={t.id}>
<div className="cmd">
<code title={t.name}>{t.name}</code>
<button
type="button"
className="icon-btn danger"
onClick={() => revoke(t)}
title="Révoquer"
aria-label={`Révoquer ${t.name}`}
>
<TrashIcon />
</button>
</div>
<small className="muted">
{t.kind === "oauth" ? `Connecteur autorisé (${t.client})` : "Jeton personnel"} · créé le {fmtDate(t.created)}
{t.lastUsed ? ` · utilisé le ${fmtDate(t.lastUsed)}` : " · jamais utilisé"}
</small>
</li>
))}
</ul>
) : (
tokens && <small className="muted">Aucun jeton ni connecteur autorisé.</small>
)}
</section>
);
}
+3
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { ApiAccess } from "./ApiAccess";
import { api, type Channel, type Settings, type Share, type UpdateStatus } from "./api";
import { CopyIcon, TrashIcon } from "./icons";
import { fmtExpiry } from "./ShareDialog";
@@ -260,6 +261,8 @@ export function SettingsDialog({ settings, update, focusExcalidraw, onChange, on
)}
</section>
<ApiAccess onError={setError} />
{error && <div className="error">{error}</div>}
<div className="modal-actions">
+20
View File
@@ -72,6 +72,16 @@ export interface Share {
expires?: string;
}
/** Jeton d'accès à l'API et au serveur MCP (api : créé ici ; oauth : autorisé depuis un client). */
export interface ApiToken {
id: string;
name: string;
kind: "api" | "oauth";
client?: string;
created: string;
lastUsed?: string;
}
/** Schéma partagé tel que servi sans authentification. */
export interface PublicScene {
name: string;
@@ -203,6 +213,16 @@ export const api = {
await request("/api/shares/revoke-all", { method: "POST" });
},
tokens: async (): Promise<ApiToken[]> => (await request("/api/tokens")).json(),
/** Le jeton en clair (token) n'est renvoyé qu'à la création. */
createToken: async (name: string): Promise<ApiToken & { token: string }> =>
(await request("/api/tokens", json({ name }))).json(),
revokeToken: async (id: string): Promise<void> => {
await request(`/api/tokens/${q(id)}`, { method: "DELETE" });
},
publicScene: async (token: string): Promise<PublicScene> =>
(await request(`/api/public/${q(token)}`)).json(),
+17
View File
@@ -1340,6 +1340,23 @@ button.chip.on,
text-overflow: ellipsis;
overflow-wrap: normal;
}
.token-create {
display: flex;
gap: 8px;
margin-top: 4px;
}
.token-create .text-input {
flex: 1;
min-width: 0;
}
.token-new {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border-radius: var(--ex-radius-lg);
border: 1px solid var(--warn);
}
@media (max-width: 768px) {
.share-view .status.readonly {
display: inline-flex;