Serveur MCP (/mcp) et jetons d'accès (Bearer, OAuth 2.1)

- internal/tokens : jetons personnels hachés (DATA_DIR/.tokens.json),
  authentification Bearer devant la session, routes /api/tokens,
  serveur d'autorisation OAuth 2.1 (DCR, PKCE S256, rotation) pour les
  connecteurs claude.ai / Claude Desktop, consentement adossé à la session.
- internal/mcp : JSON-RPC Streamable HTTP, outils list/search/read/
  write_diagram et create_folder, normalisation des éléments (label,
  liaisons start/end des flèches).
- main.go : PUBLIC_URL, OAUTH_REDIRECT_HOSTS, chemins OAuth publics.

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:09:27 +02:00
1 parent 1e4370cd0a
commit eeda956d24
7 files changed
+1143 -4

No files matched your search

+35 -4
View File
@@ -11,6 +11,7 @@ import (
"log/slog"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path"
@@ -21,7 +22,9 @@ import (
"github.com/vblogio/excalidraw-go/internal/api"
"github.com/vblogio/excalidraw-go/internal/auth"
"github.com/vblogio/excalidraw-go/internal/mcp"
"github.com/vblogio/excalidraw-go/internal/storage"
"github.com/vblogio/excalidraw-go/internal/tokens"
"github.com/vblogio/excalidraw-go/internal/update"
"github.com/vblogio/excalidraw-go/web"
)
@@ -92,6 +95,21 @@ func main() {
mux := http.NewServeMux()
spaces := storage.NewSpaces(store)
(&api.Handler{Store: store, Spaces: spaces, User: userID, MaxBody: maxMB << 20, Update: checker}).Register(mux)
// Jetons d'accès personnels et serveur OAuth, pour l'API et le serveur MCP.
tokenStore, err := tokens.Open(dataDir)
if err != nil {
slog.Error("jetons d'accès", "err", err)
os.Exit(1)
}
ts := &tokens.Server{
Store: tokenStore,
PublicURL: publicURL(),
UserID: userID,
RedirectHosts: fields(env("OAUTH_REDIRECT_HOSTS", "claude.ai,claude.com")),
ResourcePaths: []string{"/mcp"},
}
ts.Register(mux)
mux.Handle("/mcp", &mcp.Server{Spaces: spaces, User: userID, BaseURL: ts.BaseURL, MaxBody: maxMB << 20, Version: "1.0"})
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("ok"))
})
@@ -102,7 +120,7 @@ func main() {
slog.Error("authentification", "err", err)
os.Exit(1)
}
h = logRequests(securityHeaders(h))
h = logRequests(securityHeaders(ts.Middleware(h, mux)))
srv := &http.Server{
Addr: addr,
@@ -191,6 +209,18 @@ func userID(r *http.Request) string {
return storage.LocalUser
}
// publicURL est l'origine publique du service : PUBLIC_URL, sinon celle de
// OIDC_REDIRECT_URL ; vide, elle est déduite de chaque requête.
func publicURL() string {
if v := os.Getenv("PUBLIC_URL"); v != "" {
return strings.TrimRight(v, "/")
}
if u, err := url.Parse(os.Getenv("OIDC_REDIRECT_URL")); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host
}
return ""
}
// oidcConfig lit les variables OIDC_* et SESSION_*.
func oidcConfig(dataDir string) (auth.Config, error) {
cfg := auth.Config{
@@ -298,11 +328,12 @@ func spa() http.Handler {
}
// public indique les chemins servis sans authentification : sonde, schémas
// partagés (/s/<jeton> et son API) et fichiers statiques du build (code de
// l'application et polices, sans aucune donnée).
// partagés (/s/<jeton> et son API), points d'entrée OAuth (métadonnées,
// enregistrement, échange de jetons) et fichiers statiques du build (code de
// l'application et polices, sans aucune donnée). /mcp exige un jeton.
func public(p string) bool {
switch {
case p == "/healthz", strings.HasPrefix(p, "/s/"), strings.HasPrefix(p, "/api/public/"):
case p == "/healthz", strings.HasPrefix(p, "/s/"), strings.HasPrefix(p, "/api/public/"), tokens.IsPublic(p):
return true
case strings.HasPrefix(p, "/api/"), p == "/", p == "/index.html":
return false
+6
View File
@@ -22,6 +22,12 @@ func TestBasicAuthPublicPaths(t *testing.T) {
"/api/publicx": 401,
"/assets/": 401, // dossier : pas un fichier statique
"/inconnu": 401,
"/.well-known/oauth-protected-resource/mcp": 200,
"/.well-known/oauth-authorization-server": 200,
"/oauth/register": 200,
"/oauth/token": 200,
"/oauth/authorize": 401, // consentement : session requise
"/mcp": 401, // jeton requis (middleware des jetons)
}
for p, want := range cases {
rec := httptest.NewRecorder()