Files
task-master/internal/daemon/api/auth.go
T
skrueckenandCursor 2dbf0918de Add local JSON daemon and route TUI/CLI through it.
A per-DataDir background process owns ticket JSON, history, and sync so
multiple TUI instances stay consistent; MCP token APIs are ready for phase 2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-01 16:32:13 +01:00

118 lines
3.0 KiB
Go

package api
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
)
type ctxKey int
const (
ctxClientKind ctxKey = iota
ctxMCPProject
)
const (
HeaderClient = "X-TM-Client"
ClientTUI = "tui"
ClientCLI = "cli"
ClientMCP = "mcp"
)
// ClientKind returns the authenticated client kind from context.
func ClientKind(ctx context.Context) string {
v, _ := ctx.Value(ctxClientKind).(string)
return v
}
// MCPProjectID returns the project bound to a validated MCP token, if any.
func MCPProjectID(ctx context.Context) string {
v, _ := ctx.Value(ctxMCPProject).(string)
return v
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s *Server) withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/health" {
next.ServeHTTP(w, r)
return
}
kind := strings.ToLower(strings.TrimSpace(r.Header.Get(HeaderClient)))
if kind == "" {
kind = ClientTUI
}
ctx := context.WithValue(r.Context(), ctxClientKind, kind)
if kind == ClientMCP {
auth := r.Header.Get("Authorization")
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) {
writeErr(w, http.StatusUnauthorized, "mcp requires Authorization: Bearer <token>")
return
}
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
if token == "" {
writeErr(w, http.StatusUnauthorized, "empty bearer token")
return
}
projectID := strings.TrimSpace(r.Header.Get("X-TM-Project"))
if projectID == "" {
projectID = extractProjectID(r)
}
if projectID == "" {
writeErr(w, http.StatusUnauthorized, "mcp requires project id (X-TM-Project or path/body)")
return
}
meta, err := s.projects.LoadMeta(projectID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if meta.McpAuth == nil || meta.McpAuth.TokenHash == "" {
writeErr(w, http.StatusForbidden, "no mcp token configured for project")
return
}
got := hashToken(token)
if subtle.ConstantTimeCompare([]byte(got), []byte(meta.McpAuth.TokenHash)) != 1 {
writeErr(w, http.StatusForbidden, "invalid mcp token")
return
}
ctx = context.WithValue(ctx, ctxMCPProject, projectID)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// extractProjectID best-effort for auth before body is consumed.
// Path patterns: /v1/projects/{id}/...
func extractProjectID(r *http.Request) string {
if q := strings.TrimSpace(r.URL.Query().Get("projectId")); q != "" {
return q
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
// v1 projects {id} ...
if len(parts) >= 3 && parts[0] == "v1" && parts[1] == "projects" {
return parts[2]
}
return ""
}
// requireMCPProject ensures MCP calls stay on the token's project.
func requireMCPProject(r *http.Request, projectID string) bool {
if ClientKind(r.Context()) != ClientMCP {
return true
}
bound := MCPProjectID(r.Context())
return bound != "" && bound == projectID
}