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
+35
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(`<!doctype html>
|
||||
<html lang="fr"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<title>Autoriser {{.Client}}</title>
|
||||
<style>
|
||||
body{font-family:system-ui,sans-serif;max-width:28rem;margin:4rem auto;padding:0 1rem;color:#1b1b1f;background:#fff}
|
||||
@media(prefers-color-scheme:dark){body{color:#e3e3e8;background:#121212}}
|
||||
h1{font-size:1.3rem}.host{font-family:monospace}button{font:inherit;padding:.5rem 1.2rem;border-radius:.5rem;border:1px solid #888;cursor:pointer;margin-right:.5rem}
|
||||
.allow{background:#6965db;color:#fff;border-color:#6965db}small{opacity:.7}
|
||||
</style>
|
||||
<h1>Autoriser « {{.Client}} » ?</h1>
|
||||
<p>Ce client demande l'accès à vos schémas excalicloud en tant que <b>{{.Who}}</b> : lister, lire, créer et modifier des schémas et des dossiers, y compris votre espace personnel.</p>
|
||||
<p>Retour vers : <span class="host">{{.Host}}</span></p>
|
||||
<form method="post" action="/oauth/authorize">
|
||||
<input type="hidden" name="response_type" value="code">
|
||||
<input type="hidden" name="code_challenge_method" value="S256">
|
||||
<input type="hidden" name="client_id" value="{{.Req.ClientID}}">
|
||||
<input type="hidden" name="redirect_uri" value="{{.Req.Redirect}}">
|
||||
<input type="hidden" name="code_challenge" value="{{.Req.Challenge}}">
|
||||
<input type="hidden" name="state" value="{{.Req.State}}">
|
||||
<input type="hidden" name="exp" value="{{.Exp}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="allow" name="action" value="allow" autofocus>Autoriser</button>
|
||||
<button name="action" value="deny">Refuser</button>
|
||||
</form>
|
||||
<p><small>L'accès se révoque dans Paramètres › Accès API et MCP.</small></p>
|
||||
</html>`))
|
||||
|
||||
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(`<!doctype html>
|
||||
<html lang="fr"><meta charset="utf-8"><title>{{.Title}}</title>
|
||||
<body style="font-family:system-ui,sans-serif;max-width:28rem;margin:4rem auto;padding:0 1rem">
|
||||
<h1 style="font-size:1.3rem">{{.Title}}</h1><p>{{.Msg}}</p></body></html>`))
|
||||
@@ -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