- 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
211 lines
6.7 KiB
Go
211 lines
6.7 KiB
Go
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
|
|
}
|