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:
1 parent
1e4370cd0a
commit
eeda956d24
7 files changed
+1143
-4
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user