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 ") 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 }