diff --git a/cmd/server/main.go b/cmd/server/main.go index a653181..0c769fd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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/ et son API) et fichiers statiques du build (code de -// l'application et polices, sans aucune donnée). +// partagés (/s/ 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 diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index fe40153..f17bc00 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -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() diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index efd6de3..644b303 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -473,6 +473,11 @@ func UserFrom(ctx context.Context) *User { return u } +// WithUser renvoie ctx portant l'utilisateur u (authentification par jeton d'API). +func WithUser(ctx context.Context, u *User) context.Context { + return context.WithValue(ctx, ctxKey{}, u) +} + // Session renvoie l'utilisateur du cookie de session s'il est valide. func (a *OIDC) session(r *http.Request) (session, bool) { var s session diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..7fc196b --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,160 @@ +// Package mcp expose les schémas à un client MCP (Model Context Protocol) en +// transport « Streamable HTTP » : JSON-RPC 2.0 par POST sur une seule route, +// réponses JSON (pas de flux SSE ni de session : le serveur n'émet rien de +// lui-même). L'authentification est faite en amont (jeton Bearer, paquet +// tokens) ; les chemins sont ceux de l'utilisateur (storage.Space). +package mcp + +import ( + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "slices" + + "github.com/vblogio/excalidraw-go/internal/storage" +) + +// Versions du protocole acceptées, la plus récente d'abord. +var protocolVersions = []string{"2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"} + +// Server répond aux requêtes MCP. +type Server struct { + Spaces *storage.Spaces + User func(*http.Request) string // identifiant de l'utilisateur (storage.UserID) + BaseURL func(*http.Request) string // origine publique, pour les liens vers les schémas + MaxBody int64 + Version string +} + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +const ( + errParse = -32700 + errInvalidRequest = -32600 + errMethod = -32601 + errParams = -32602 +) + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + // Pas de flux serveur → client (GET) ni de session à fermer (DELETE). + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "méthode non prise en charge", http.StatusMethodNotAllowed) + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.MaxBody)) + if err != nil { + reply(w, response{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{errInvalidRequest, "requête trop volumineuse"}}) + return + } + var req request + if err := json.Unmarshal(body, &req); err != nil || req.JSONRPC != "2.0" { + reply(w, response{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{errParse, "message JSON-RPC 2.0 attendu (les lots ne sont pas pris en charge)"}}) + return + } + if len(req.ID) == 0 || req.Method == "" { + // Notification (initialized, cancelled…) ou réponse du client. + w.WriteHeader(http.StatusAccepted) + return + } + res := response{JSONRPC: "2.0", ID: req.ID} + res.Result, res.Error = s.call(r, req) + reply(w, res) +} + +func reply(w http.ResponseWriter, res response) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(res) +} + +func (s *Server) call(r *http.Request, req request) (any, *rpcError) { + switch req.Method { + case "initialize": + var p struct { + ProtocolVersion string `json:"protocolVersion"` + } + _ = json.Unmarshal(req.Params, &p) + v := protocolVersions[0] + if slices.Contains(protocolVersions, p.ProtocolVersion) { + v = p.ProtocolVersion + } + return map[string]any{ + "protocolVersion": v, + "capabilities": map[string]any{"tools": map[string]any{"listChanged": false}}, + "serverInfo": map[string]any{"name": "excalicloud", "title": "Excalicloud", "version": s.Version}, + "instructions": instructions, + }, nil + case "ping": + return map[string]any{}, nil + case "tools/list": + return map[string]any{"tools": tools}, nil + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if json.Unmarshal(req.Params, &p) != nil { + return nil, &rpcError{errParams, "paramètres invalides"} + } + h, ok := handlers[p.Name] + if !ok { + return nil, &rpcError{errParams, "outil inconnu : " + p.Name} + } + if len(p.Arguments) == 0 || string(p.Arguments) == "null" { + p.Arguments = json.RawMessage("{}") + } + u, err := s.Spaces.For(s.User(r)) + if err != nil { + return toolError(err), nil + } + out, err := h(&call{s: s, r: r, u: u}, p.Arguments) + if err != nil { + return toolError(err), nil + } + text, _ := json.MarshalIndent(out, "", " ") + return map[string]any{ + "content": []any{map[string]any{"type": "text", "text": string(text)}}, + "structuredContent": out, + }, nil + } + return nil, &rpcError{errMethod, "méthode inconnue : " + req.Method} +} + +// toolError renvoie une erreur d'outil lisible par le modèle (isError). +func toolError(err error) map[string]any { + msg := err.Error() + var tooBig *http.MaxBytesError + var arg argError + switch { + case errors.Is(err, storage.ErrNotFound): + msg = "introuvable : " + msg + case errors.Is(err, storage.ErrExists): + msg = "existe déjà (overwrite: true pour remplacer) : " + msg + case errors.Is(err, storage.ErrInvalidPath), errors.Is(err, storage.ErrInvalidJSON), + errors.Is(err, storage.ErrNotEmpty), errors.As(err, &tooBig), errors.As(err, &arg): + default: + slog.Error("mcp", "err", err) + msg = "erreur interne" + } + return map[string]any{"content": []any{map[string]any{"type": "text", "text": msg}}, "isError": true} +} diff --git a/internal/tokens/http.go b/internal/tokens/http.go new file mode 100644 index 0000000..93eaada --- /dev/null +++ b/internal/tokens/http.go @@ -0,0 +1,210 @@ +package tokens + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strings" + + "github.com/vblogio/excalidraw-go/internal/auth" +) + +// Server expose les jetons en HTTP : authentification Bearer, gestion des +// jetons personnels (/api/tokens) et serveur d'autorisation OAuth (oauth.go). +type Server struct { + Store *Store + // PublicURL est l'origine publique (PUBLIC_URL) ; vide : déduite de la + // requête (en-têtes X-Forwarded-Proto et X-Forwarded-Host compris). + PublicURL string + // UserID identifie l'utilisateur d'une requête authentifiée (session ou jeton). + UserID func(*http.Request) string + // RedirectHosts : hôtes autorisés pour les redirections OAuth en https + // (les adresses de bouclage en http le sont toujours, pour Claude Code). + RedirectHosts []string + // ResourcePaths : chemins réservés aux jetons (serveur MCP). + ResourcePaths []string + + codes codeStore +} + +type tokenKey struct{} + +// FromContext renvoie le jeton qui a authentifié la requête (nil : session). +func FromContext(ctx context.Context) *Token { + t, _ := ctx.Value(tokenKey{}).(*Token) + return t +} + +// BaseURL renvoie l'origine publique du service. +func (s *Server) BaseURL(r *http.Request) string { + if s.PublicURL != "" { + return strings.TrimRight(s.PublicURL, "/") + } + scheme := "http" + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + scheme = "https" + } + host := r.Host + if h := r.Header.Get("X-Forwarded-Host"); h != "" { + host, _, _ = strings.Cut(h, ",") + host = strings.TrimSpace(host) + } + return scheme + "://" + host +} + +func (s *Server) resource(p string) bool { + for _, r := range s.ResourcePaths { + if p == r || strings.HasPrefix(p, r+"/") { + return true + } + } + return false +} + +// metadataURL est l'adresse des métadonnées de ressource protégée (RFC 9728). +func (s *Server) metadataURL(r *http.Request) string { + return s.BaseURL(r) + "/.well-known/oauth-protected-resource" +} + +func bearer(r *http.Request) (string, bool) { + scheme, tok, ok := strings.Cut(r.Header.Get("Authorization"), " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + return "", false + } + return strings.TrimSpace(tok), true +} + +// Middleware authentifie les requêtes porteuses d'un jeton (Authorization: +// Bearer) et les transmet directement à mux avec l'utilisateur du jeton ; les +// autres suivent next (session OIDC, Basic…). Les chemins ResourcePaths +// exigent un jeton : sans jeton, la réponse 401 indique où obtenir un jeton +// OAuth (WWW-Authenticate, RFC 9728). +func (s *Server) Middleware(next, mux http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok, ok := bearer(r) + if !ok && !s.resource(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + meta := `Bearer resource_metadata="` + s.metadataURL(r) + `"` + t, valid := s.Store.Lookup(tok) + if !valid { + if ok { + meta += `, error="invalid_token"` + } + w.Header().Set("WWW-Authenticate", meta) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "jeton d'accès absent, invalide ou expiré"}) + return + } + ctx := context.WithValue(r.Context(), tokenKey{}, &t) + if t.User != nil { + ctx = auth.WithUser(ctx, t.User) + } + mux.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// Register enregistre les routes des jetons et du serveur OAuth. +// +// GET /api/tokens jetons de l'utilisateur +// POST /api/tokens {"name"} nouveau jeton personnel (renvoyé une seule fois) +// DELETE /api/tokens/{id} révocation +func (s *Server) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /api/tokens", s.session(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.Store.List(s.UserID(r))) + })) + mux.HandleFunc("POST /api/tokens", s.session(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req) != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "requête JSON invalide"}) + return + } + secret, info, err := s.Store.Create(s.UserID(r), auth.UserFrom(r.Context()), req.Name) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrInvalid) || errors.Is(err, ErrTooMany) { + status = http.StatusBadRequest + } + writeJSON(w, status, map[string]string{"error": err.Error()}) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusCreated, struct { + Info + Token string `json:"token"` + }{info, secret}) + })) + mux.HandleFunc("DELETE /api/tokens/{id}", s.session(func(w http.ResponseWriter, r *http.Request) { + if err := s.Store.Revoke(s.UserID(r), r.PathValue("id")); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, ErrNotFound) { + status = http.StatusNotFound + } + writeJSON(w, status, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) + })) + s.registerOAuth(mux) +} + +// session refuse les requêtes authentifiées par un jeton : un jeton ne peut +// pas créer, lister ni révoquer de jetons. +func (s *Server) session(h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if FromContext(r.Context()) != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "gestion des jetons réservée à la session de l'interface"}) + return + } + h(w, r) + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// redirectHost renvoie l'hôte d'une URI de redirection acceptable : https vers +// un hôte autorisé, ou http vers une adresse de bouclage (tout port). +func (s *Server) redirectHost(raw string) (string, bool) { + u, err := url.Parse(raw) + if err != nil || u.Fragment != "" || u.User != nil || u.Host == "" { + return "", false + } + host := strings.ToLower(u.Hostname()) + switch u.Scheme { + case "http": + return host, loopback(host) + case "https": + for _, h := range s.RedirectHosts { + if h = strings.ToLower(strings.TrimSpace(h)); h != "" && (host == h || strings.HasSuffix(host, "."+h)) { + return host, true + } + } + } + return "", false +} + +func loopback(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +// sameRedirect compare deux URI de redirection ; le port d'une adresse de +// bouclage est ignoré (RFC 8252 §7.3 : port éphémère choisi par le client). +func sameRedirect(registered, got string) bool { + if registered == got { + return true + } + a, err1 := url.Parse(registered) + b, err2 := url.Parse(got) + if err1 != nil || err2 != nil || a.Scheme != "http" || b.Scheme != "http" || !loopback(a.Hostname()) { + return false + } + return a.Hostname() == b.Hostname() && a.Path == b.Path && a.RawQuery == b.RawQuery +} diff --git a/internal/tokens/oauth.go b/internal/tokens/oauth.go new file mode 100644 index 0000000..1c2a1b3 --- /dev/null +++ b/internal/tokens/oauth.go @@ -0,0 +1,405 @@ +package tokens + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "html/template" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/vblogio/excalidraw-go/internal/auth" +) + +// Serveur d'autorisation OAuth 2.1 minimal, pour les clients MCP qui ne savent +// pas envoyer de jeton fixe (connecteurs personnalisés de claude.ai, Claude +// Desktop et mobile) : +// +// GET /.well-known/oauth-protected-resource[/mcp] ressource protégée (RFC 9728) +// GET /.well-known/oauth-authorization-server métadonnées (RFC 8414) +// POST /oauth/register enregistrement dynamique du client (RFC 7591) +// GET /oauth/authorize page de consentement (session de l'interface requise) +// POST /oauth/authorize accord ou refus, puis redirection avec le code +// POST /oauth/token échange du code (PKCE S256) et rafraîchissement +// +// L'utilisateur se connecte comme d'habitude (OIDC, Basic) puis autorise le +// client : le jeton délivré est rattaché à son compte et révocable depuis les +// paramètres. Les identifiants de clients sont signés (aucun état serveur). + +const ( + codeTTL = 5 * time.Minute + consentTTL = 10 * time.Minute + Scope = "mcp" +) + +// OAuthPaths sont les chemins OAuth servis sans session (à déclarer publics). +var OAuthPaths = []string{"/.well-known/oauth-protected-resource", "/.well-known/oauth-authorization-server", "/oauth/register", "/oauth/token"} + +// IsPublic indique si p est un chemin OAuth servi sans session. +func IsPublic(p string) bool { + for _, o := range OAuthPaths { + if p == o || strings.HasPrefix(p, o+"/") { + return true + } + } + return false +} + +type client struct { + Name string `json:"n"` + Redirect []string `json:"r"` +} + +type authCode struct { + clientID, redirect, challenge, name, client string + userID string + user *auth.User + exp time.Time +} + +type codeStore struct { + mu sync.Mutex + codes map[string]authCode +} + +func (c *codeStore) put(code string, a authCode) { + c.mu.Lock() + defer c.mu.Unlock() + if c.codes == nil { + c.codes = map[string]authCode{} + } + now := time.Now() + for k, v := range c.codes { + if now.After(v.exp) { + delete(c.codes, k) + } + } + c.codes[code] = a +} + +func (c *codeStore) take(code string) (authCode, bool) { + c.mu.Lock() + defer c.mu.Unlock() + a, ok := c.codes[code] + delete(c.codes, code) + return a, ok && time.Now().Before(a.exp) +} + +func (s *Server) mac(parts ...string) string { + m := hmac.New(sha256.New, s.Store.Key()) + m.Write([]byte(strings.Join(parts, "\x00"))) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +func (s *Server) encodeClient(c client) string { + raw, _ := json.Marshal(c) + p := base64.RawURLEncoding.EncodeToString(raw) + return p + "." + s.mac("client", p) +} + +func (s *Server) decodeClient(id string) (client, bool) { + var c client + p, sig, ok := strings.Cut(id, ".") + if !ok || !hmac.Equal([]byte(sig), []byte(s.mac("client", p))) { + return c, false + } + raw, err := base64.RawURLEncoding.DecodeString(p) + return c, err == nil && json.Unmarshal(raw, &c) == nil +} + +func (s *Server) registerOAuth(mux *http.ServeMux) { + resource := func(w http.ResponseWriter, r *http.Request) { + base := s.BaseURL(r) + res := base + if len(s.ResourcePaths) > 0 { + res += s.ResourcePaths[0] + } + writeJSON(w, http.StatusOK, map[string]any{ + "resource": res, + "authorization_servers": []string{base}, + "scopes_supported": []string{Scope}, + "bearer_methods_supported": []string{"header"}, + "resource_name": "excalicloud", + }) + } + mux.HandleFunc("GET /.well-known/oauth-protected-resource", resource) + mux.HandleFunc("GET /.well-known/oauth-protected-resource/", resource) + mux.HandleFunc("GET /.well-known/oauth-authorization-server", func(w http.ResponseWriter, r *http.Request) { + base := s.BaseURL(r) + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": base, + "authorization_endpoint": base + "/oauth/authorize", + "token_endpoint": base + "/oauth/token", + "registration_endpoint": base + "/oauth/register", + "scopes_supported": []string{Scope}, + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code", "refresh_token"}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + }) + }) + mux.HandleFunc("POST /oauth/register", s.register) + mux.HandleFunc("GET /oauth/authorize", s.authorize) + mux.HandleFunc("POST /oauth/authorize", s.consent) + mux.HandleFunc("POST /oauth/token", s.token) +} + +func oauthErr(w http.ResponseWriter, status int, code, desc string) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, map[string]string{"error": code, "error_description": desc}) +} + +// register : enregistrement dynamique (RFC 7591), client public uniquement. +func (s *Server) register(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"client_name"` + Redirect []string `json:"redirect_uris"` + } + if json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req) != nil { + oauthErr(w, http.StatusBadRequest, "invalid_client_metadata", "JSON invalide") + return + } + if len(req.Redirect) == 0 || len(req.Redirect) > 5 { + oauthErr(w, http.StatusBadRequest, "invalid_redirect_uri", "redirect_uris requis (5 au plus)") + return + } + for _, u := range req.Redirect { + if _, ok := s.redirectHost(u); !ok { + oauthErr(w, http.StatusBadRequest, "invalid_redirect_uri", + "redirection non autorisée : "+u+" (voir OAUTH_REDIRECT_HOSTS)") + return + } + } + name, err := cleanName(req.Name) + if err != nil { + name = "Client MCP" + } + id := s.encodeClient(client{Name: name, Redirect: req.Redirect}) + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusCreated, map[string]any{ + "client_id": id, + "client_id_issued_at": time.Now().Unix(), + "client_name": name, + "redirect_uris": req.Redirect, + "grant_types": []string{"authorization_code", "refresh_token"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + "scope": Scope, + }) +} + +type authReq struct { + ClientID, Redirect, Challenge, State string + client client + host string +} + +// parseAuthorize valide les paramètres de la demande d'autorisation. Une +// erreur sur le client ou la redirection s'affiche (pas de redirection vers +// une adresse non vérifiée) ; les autres sont renvoyées au client. +func (s *Server) parseAuthorize(w http.ResponseWriter, r *http.Request, v url.Values) (authReq, bool) { + a := authReq{ClientID: v.Get("client_id"), Redirect: v.Get("redirect_uri"), Challenge: v.Get("code_challenge"), State: v.Get("state")} + c, ok := s.decodeClient(a.ClientID) + if !ok { + page(w, http.StatusBadRequest, "Client inconnu", "Identifiant de client OAuth invalide.") + return a, false + } + a.client = c + if a.Redirect == "" && len(c.Redirect) == 1 { + a.Redirect = c.Redirect[0] + } + match := false + for _, reg := range c.Redirect { + match = match || sameRedirect(reg, a.Redirect) + } + if a.host, ok = s.redirectHost(a.Redirect); !match || !ok { + page(w, http.StatusBadRequest, "Redirection refusée", "L'adresse de retour ne correspond pas au client enregistré.") + return a, false + } + switch { + case v.Get("response_type") != "code": + s.redirectErr(w, r, a, "unsupported_response_type") + case len(a.Challenge) < 43 || v.Get("code_challenge_method") != "S256": + s.redirectErr(w, r, a, "invalid_request") + default: + return a, true + } + return a, false +} + +func (s *Server) redirectErr(w http.ResponseWriter, r *http.Request, a authReq, code string) { + u, _ := url.Parse(a.Redirect) + q := u.Query() + q.Set("error", code) + if a.State != "" { + q.Set("state", a.State) + } + q.Set("iss", s.BaseURL(r)) + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +// csrf lie le formulaire de consentement à l'utilisateur et à la demande. +func (s *Server) csrf(userID string, a authReq, exp int64) string { + return s.mac("consent", userID, a.ClientID, a.Redirect, a.Challenge, a.State, strconv.FormatInt(exp, 10)) +} + +func (s *Server) authorize(w http.ResponseWriter, r *http.Request) { + if FromContext(r.Context()) != nil { + page(w, http.StatusForbidden, "Accès refusé", "Une autorisation se donne depuis le navigateur.") + return + } + a, ok := s.parseAuthorize(w, r, r.URL.Query()) + if !ok { + return + } + who := "l'utilisateur local" + if u := auth.UserFrom(r.Context()); u != nil { + who = u.Name + if u.Email != "" { + who += " (" + u.Email + ")" + } + } + exp := time.Now().Add(consentTTL).Unix() + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = consentPage.Execute(w, map[string]any{ + "Client": a.client.Name, "Host": a.host, "Who": who, "Req": a, + "Exp": exp, "CSRF": s.csrf(s.UserID(r), a, exp), + }) +} + +func (s *Server) consent(w http.ResponseWriter, r *http.Request) { + if FromContext(r.Context()) != nil { + page(w, http.StatusForbidden, "Accès refusé", "Une autorisation se donne depuis le navigateur.") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if r.ParseForm() != nil { + page(w, http.StatusBadRequest, "Requête invalide", "Formulaire illisible.") + return + } + a, ok := s.parseAuthorize(w, r, r.PostForm) + if !ok { + return + } + exp, _ := strconv.ParseInt(r.PostForm.Get("exp"), 10, 64) + userID := s.UserID(r) + if time.Now().Unix() > exp || !hmac.Equal([]byte(r.PostForm.Get("csrf")), []byte(s.csrf(userID, a, exp))) { + page(w, http.StatusBadRequest, "Demande expirée", "Relancer la connexion depuis le client.") + return + } + if r.PostForm.Get("action") != "allow" { + s.redirectErr(w, r, a, "access_denied") + return + } + code := random(32) + s.codes.put(code, authCode{ + clientID: a.ClientID, redirect: a.Redirect, challenge: a.Challenge, name: a.client.Name, client: a.host, + userID: userID, user: auth.UserFrom(r.Context()), exp: time.Now().Add(codeTTL), + }) + u, _ := url.Parse(a.Redirect) + q := u.Query() + q.Set("code", code) + if a.State != "" { + q.Set("state", a.State) + } + q.Set("iss", s.BaseURL(r)) + u.RawQuery = q.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +func (s *Server) token(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if r.ParseForm() != nil { + oauthErr(w, http.StatusBadRequest, "invalid_request", "formulaire illisible") + return + } + f := r.PostForm + clientID := f.Get("client_id") + if _, ok := s.decodeClient(clientID); !ok { + oauthErr(w, http.StatusUnauthorized, "invalid_client", "client inconnu") + return + } + var access, refresh string + var err error + switch f.Get("grant_type") { + case "authorization_code": + c, ok := s.codes.take(f.Get("code")) + sum := sha256.Sum256([]byte(f.Get("code_verifier"))) + verifier := base64.RawURLEncoding.EncodeToString(sum[:]) + if !ok || c.clientID != clientID || + (f.Get("redirect_uri") != "" && f.Get("redirect_uri") != c.redirect) || + subtle.ConstantTimeCompare([]byte(verifier), []byte(c.challenge)) != 1 { + oauthErr(w, http.StatusBadRequest, "invalid_grant", "code invalide, expiré ou vérificateur PKCE incorrect") + return + } + access, refresh, err = s.Store.Grant(c.userID, c.user, c.name, clientID, c.client) + case "refresh_token": + access, refresh, err = s.Store.Refresh(f.Get("refresh_token"), clientID) + if err == ErrNotFound { + oauthErr(w, http.StatusBadRequest, "invalid_grant", "jeton de rafraîchissement invalide ou révoqué") + return + } + default: + oauthErr(w, http.StatusBadRequest, "unsupported_grant_type", "authorization_code ou refresh_token") + return + } + if err != nil { + oauthErr(w, http.StatusInternalServerError, "server_error", "enregistrement du jeton impossible") + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, map[string]any{ + "access_token": access, + "token_type": "Bearer", + "expires_in": int(AccessTTL.Seconds()), + "refresh_token": refresh, + "scope": Scope, + }) +} + +var consentPage = template.Must(template.New("consent").Parse(` + +Autoriser {{.Client}} + +

Autoriser « {{.Client}} » ?

+

Ce client demande l'accès à vos schémas excalicloud en tant que {{.Who}} : lister, lire, créer et modifier des schémas et des dossiers, y compris votre espace personnel.

+

Retour vers : {{.Host}}

+
+ + + + + + + + + + +
+

L'accès se révoque dans Paramètres › Accès API et MCP.

+`)) + +func page(w http.ResponseWriter, status int, title, msg string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = simplePage.Execute(w, map[string]string{"Title": title, "Msg": msg}) +} + +var simplePage = template.Must(template.New("page").Parse(` +{{.Title}} + +

{{.Title}}

{{.Msg}}

`)) diff --git a/internal/tokens/tokens.go b/internal/tokens/tokens.go new file mode 100644 index 0000000..2fd2748 --- /dev/null +++ b/internal/tokens/tokens.go @@ -0,0 +1,322 @@ +// Package tokens gère les jetons d'accès personnels à l'API et au serveur MCP : +// jetons créés depuis l'interface (sans expiration) et jetons délivrés par le +// petit serveur OAuth 2.1 intégré (connecteurs claude.ai, Claude Desktop…). +// +// Les jetons ne sont conservés que hachés (SHA-256) dans DATA_DIR/.tokens.json +// (0600), rattachés à l'utilisateur qui les a créés : ils donnent accès à son +// espace personnel et à ses favoris, comme sa session. +package tokens + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/vblogio/excalidraw-go/internal/auth" +) + +const ( + fileName = ".tokens.json" + + apiPrefix = "exc_" // jeton personnel + accessPrefix = "exca_" // jeton d'accès OAuth + refreshPrefix = "excr_" // jeton de rafraîchissement OAuth + + KindAPI = "api" + KindOAuth = "oauth" + + // AccessTTL est la durée de vie d'un jeton d'accès OAuth ; le client le + // renouvelle avec son jeton de rafraîchissement (rotation à chaque usage). + AccessTTL = time.Hour + // IdleTTL : une autorisation OAuth inutilisée pendant cette durée expire. + IdleTTL = 90 * 24 * time.Hour + + MaxNameLen = 80 + maxAPIPerUser = 50 + maxOAuthPerUser = 20 + touchEvery = time.Minute // fréquence d'enregistrement de LastUsed +) + +var ( + ErrNotFound = errors.New("jeton introuvable") + ErrInvalid = errors.New("nom de jeton invalide") + ErrTooMany = errors.New("trop de jetons : en révoquer avant d'en créer") +) + +// Token est un jeton enregistré. Les champs *Hash ne sortent jamais du paquet. +type Token struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + UserID string `json:"userId"` + User *auth.User `json:"user,omitempty"` // nil : authentification locale ou absente + Hash string `json:"hash"` + Expires time.Time `json:"expires,omitzero"` // jeton d'accès OAuth + RefreshHash string `json:"refreshHash,omitempty"` // OAuth + ClientID string `json:"clientId,omitempty"` // OAuth + Client string `json:"client,omitempty"` // hôte de redirection du client OAuth + Created time.Time `json:"created"` + LastUsed time.Time `json:"lastUsed,omitzero"` +} + +// Info est la vue d'un jeton renvoyée à l'interface. +type Info struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Client string `json:"client,omitempty"` + Created time.Time `json:"created"` + LastUsed time.Time `json:"lastUsed,omitzero"` +} + +func (t *Token) info() Info { + return Info{ID: t.ID, Name: t.Name, Kind: t.Kind, Client: t.Client, Created: t.Created, LastUsed: t.LastUsed} +} + +type fileData struct { + Key string `json:"key"` // clé HMAC (identifiants de clients OAuth, formulaires) + Tokens []*Token `json:"tokens"` +} + +// Store conserve les jetons dans dir/.tokens.json. +type Store struct { + mu sync.Mutex + file string + key []byte + tokens []*Token + now func() time.Time +} + +// Open charge (ou crée) le fichier des jetons de dir. +func Open(dir string) (*Store, error) { + s := &Store{file: filepath.Join(dir, fileName), now: time.Now} + b, err := os.ReadFile(s.file) + switch { + case errors.Is(err, os.ErrNotExist): + case err != nil: + return nil, err + default: + var d fileData + if err := json.Unmarshal(b, &d); err != nil { + return nil, errors.New(s.file + " invalide : " + err.Error()) + } + s.tokens = d.Tokens + s.key, _ = hex.DecodeString(d.Key) + } + if len(s.key) < 32 { + s.key = make([]byte, 32) + if _, err := rand.Read(s.key); err != nil { + return nil, err + } + if err := s.save(); err != nil { + return nil, err + } + } + return s, nil +} + +// Key renvoie la clé HMAC propre au serveur. +func (s *Store) Key() []byte { return s.key } + +func (s *Store) save() error { + b, err := json.MarshalIndent(fileData{Key: hex.EncodeToString(s.key), Tokens: s.tokens}, "", " ") + if err != nil { + return err + } + tmp := s.file + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.file) +} + +func random(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func hash(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + +func cleanName(name string) (string, error) { + name = strings.Join(strings.Fields(name), " ") + if name == "" || utf8.RuneCountInString(name) > MaxNameLen { + return "", ErrInvalid + } + return name, nil +} + +// purge retire les autorisations OAuth inactives et, au-delà de max jetons +// d'un type pour un utilisateur, les moins récemment utilisés. +func (s *Store) purge(userID, kind string, max int) { + now := s.now() + keep := s.tokens[:0] + var mine []*Token + for _, t := range s.tokens { + if t.Kind == KindOAuth && now.Sub(lastActivity(t)) > IdleTTL { + continue + } + keep = append(keep, t) + if t.UserID == userID && t.Kind == kind { + mine = append(mine, t) + } + } + s.tokens = keep + if len(mine) < max { + return + } + sort.Slice(mine, func(i, j int) bool { return lastActivity(mine[i]).Before(lastActivity(mine[j])) }) + drop := map[*Token]bool{} + for _, t := range mine[:len(mine)-max+1] { + drop[t] = true + } + keep = s.tokens[:0] + for _, t := range s.tokens { + if !drop[t] { + keep = append(keep, t) + } + } + s.tokens = keep +} + +func lastActivity(t *Token) time.Time { + if t.LastUsed.After(t.Created) { + return t.LastUsed + } + return t.Created +} + +// Create crée un jeton personnel pour l'utilisateur ; le jeton en clair n'est +// renvoyé qu'ici. +func (s *Store) Create(userID string, u *auth.User, name string) (string, Info, error) { + name, err := cleanName(name) + if err != nil { + return "", Info{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, t := range s.tokens { + if t.UserID == userID && t.Kind == KindAPI { + n++ + } + } + if n >= maxAPIPerUser { + return "", Info{}, ErrTooMany + } + secret := apiPrefix + random(32) + t := &Token{ID: random(9), Name: name, Kind: KindAPI, UserID: userID, User: u, Hash: hash(secret), Created: s.now().UTC()} + s.tokens = append(s.tokens, t) + if err := s.save(); err != nil { + s.tokens = s.tokens[:len(s.tokens)-1] + return "", Info{}, err + } + return secret, t.info(), nil +} + +// Grant enregistre une autorisation OAuth et renvoie ses jetons. +func (s *Store) Grant(userID string, u *auth.User, name, clientID, client string) (access, refresh string, err error) { + if name, err = cleanName(name); err != nil { + name = "Client OAuth" + } + s.mu.Lock() + defer s.mu.Unlock() + s.purge(userID, KindOAuth, maxOAuthPerUser) + access, refresh = accessPrefix+random(32), refreshPrefix+random(32) + now := s.now().UTC() + s.tokens = append(s.tokens, &Token{ + ID: random(9), Name: name, Kind: KindOAuth, UserID: userID, User: u, + Hash: hash(access), Expires: now.Add(AccessTTL), RefreshHash: hash(refresh), + ClientID: clientID, Client: client, Created: now, LastUsed: now, + }) + return access, refresh, s.save() +} + +// Refresh renouvelle les jetons d'une autorisation OAuth (rotation : l'ancien +// jeton de rafraîchissement n'est plus valable). +func (s *Store) Refresh(refresh, clientID string) (access, newRefresh string, err error) { + s.mu.Lock() + defer s.mu.Unlock() + h := hash(refresh) + for _, t := range s.tokens { + if t.Kind != KindOAuth || t.RefreshHash != h { + continue + } + if t.ClientID != clientID || s.now().Sub(lastActivity(t)) > IdleTTL { + return "", "", ErrNotFound + } + access, newRefresh = accessPrefix+random(32), refreshPrefix+random(32) + t.Hash, t.RefreshHash = hash(access), hash(newRefresh) + t.Expires = s.now().UTC().Add(AccessTTL) + t.LastUsed = s.now().UTC() + return access, newRefresh, s.save() + } + return "", "", ErrNotFound +} + +// Lookup renvoie le jeton correspondant à secret s'il est valide. +func (s *Store) Lookup(secret string) (Token, bool) { + if !strings.HasPrefix(secret, apiPrefix) && !strings.HasPrefix(secret, accessPrefix) { + return Token{}, false + } + h := hash(secret) + s.mu.Lock() + defer s.mu.Unlock() + now := s.now() + for _, t := range s.tokens { + if t.Hash != h { + continue + } + if t.Kind == KindOAuth && now.After(t.Expires) { + return Token{}, false + } + if now.Sub(t.LastUsed) > touchEvery { + t.LastUsed = now.UTC() + _ = s.save() + } + return *t, true + } + return Token{}, false +} + +// List renvoie les jetons de l'utilisateur, les plus récents d'abord. +func (s *Store) List(userID string) []Info { + s.mu.Lock() + defer s.mu.Unlock() + out := []Info{} + for _, t := range s.tokens { + if t.UserID == userID { + out = append(out, t.info()) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) }) + return out +} + +// Revoke supprime un jeton de l'utilisateur. +func (s *Store) Revoke(userID, id string) error { + s.mu.Lock() + defer s.mu.Unlock() + for i, t := range s.tokens { + if t.ID == id && t.UserID == userID { + s.tokens = append(s.tokens[:i], s.tokens[i+1:]...) + return s.save() + } + } + return ErrNotFound +}