github.com/versent/saml2aws@v2.17.0+incompatible/pkg/provider/jumpcloud/jumpcloud.go (about)

     1  package jumpcloud
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"log"
     8  	"net/http"
     9  	"regexp"
    10  	"strings"
    11  
    12  	"github.com/PuerkitoBio/goquery"
    13  	"github.com/pkg/errors"
    14  	"github.com/versent/saml2aws/pkg/cfg"
    15  	"github.com/versent/saml2aws/pkg/creds"
    16  	"github.com/versent/saml2aws/pkg/prompter"
    17  	"github.com/versent/saml2aws/pkg/provider"
    18  )
    19  
    20  const (
    21  	jcSSOBaseURL  = "https://sso.jumpcloud.com/"
    22  	xsrfURL       = "https://console.jumpcloud.com/userconsole/xsrf"
    23  	authSubmitURL = "https://console.jumpcloud.com/userconsole/auth"
    24  )
    25  
    26  // Client is a wrapper representing a JumpCloud SAML client
    27  type Client struct {
    28  	client *provider.HTTPClient
    29  }
    30  
    31  // XSRF is for unmarshalling the xsrf token in the response
    32  type XSRF struct {
    33  	Token string `json:"xsrf"`
    34  }
    35  
    36  // AuthRequest is to be sent to JumpCloud as the auth req body
    37  type AuthRequest struct {
    38  	Context    string
    39  	RedirectTo string
    40  	Email      string
    41  	Password   string
    42  	OTP        string
    43  }
    44  
    45  // JCRedirect is for unmarshalling the redirect address from the response after the auth
    46  type JCRedirect struct {
    47  	Address string `json:"redirectTo"`
    48  }
    49  
    50  // New creates a new JumpCloud client
    51  func New(idpAccount *cfg.IDPAccount) (*Client, error) {
    52  
    53  	tr := provider.NewDefaultTransport(idpAccount.SkipVerify)
    54  
    55  	client, err := provider.NewHTTPClient(tr)
    56  	if err != nil {
    57  		return nil, errors.Wrap(err, "error building http client")
    58  	}
    59  
    60  	return &Client{
    61  		client: client,
    62  	}, nil
    63  }
    64  
    65  // Authenticate logs into JumpCloud and returns a SAML response
    66  func (jc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
    67  	var samlAssertion string
    68  	var a AuthRequest
    69  	re := regexp.MustCompile(jcSSOBaseURL)
    70  
    71  	// Start by getting the XSRF Token
    72  	res, err := jc.client.Get(xsrfURL)
    73  	if err != nil {
    74  		return samlAssertion, errors.Wrap(err, "error retieving XSRF Token")
    75  	}
    76  
    77  	// Grab the web response that has the xsrf in it
    78  	xsrfBody, err := ioutil.ReadAll(res.Body)
    79  
    80  	// Unmarshall the answer and store the token
    81  	var x = new(XSRF)
    82  	err = json.Unmarshal(xsrfBody, &x)
    83  	if err != nil {
    84  		log.Fatalf("Error unmarshalling xsrf response! %v", err)
    85  	}
    86  
    87  	// Populate our Auth body for the POST
    88  	a.Context = "sso"
    89  	a.RedirectTo = re.ReplaceAllString(loginDetails.URL, "")
    90  	a.Email = loginDetails.Username
    91  	a.Password = loginDetails.Password
    92  
    93  	authBody, err := json.Marshal(a)
    94  	if err != nil {
    95  		return samlAssertion, errors.Wrap(err, "failed to build auth request body")
    96  	}
    97  
    98  	// Generate our auth request
    99  	req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(string(authBody)))
   100  	if err != nil {
   101  		return samlAssertion, errors.Wrap(err, "error building authentication request")
   102  	}
   103  
   104  	// Add the necessary headers to the auth request
   105  	req.Header.Add("X-Xsrftoken", x.Token)
   106  	req.Header.Add("Accept", "application/json")
   107  	req.Header.Add("Content-Type", "application/json")
   108  
   109  	res, err = jc.client.Do(req)
   110  	if err != nil {
   111  		return samlAssertion, errors.Wrap(err, "error retrieving login form")
   112  	}
   113  
   114  	// Check if we get a 401.  If we did, MFA is required and the OTP was not provided.
   115  	// Get the OTP and resubmit.
   116  	if res.StatusCode == 401 {
   117  		// Get the user's MFA token and re-build the body
   118  		a.OTP = loginDetails.MFAToken
   119  		if a.OTP == "" {
   120  			a.OTP = prompter.StringRequired("MFA Token")
   121  		}
   122  
   123  		authBody, err = json.Marshal(a)
   124  		if err != nil {
   125  			return samlAssertion, errors.Wrap(err, "error building authentication req body after getting MFA Token")
   126  		}
   127  
   128  		// Re-request with our OTP
   129  		req, err = http.NewRequest("POST", authSubmitURL, strings.NewReader(string(authBody)))
   130  		if err != nil {
   131  			return samlAssertion, errors.Wrap(err, "error building MFA authentication request")
   132  		}
   133  
   134  		// Re-add the necessary headers to our remade auth request
   135  		req.Header.Add("X-Xsrftoken", x.Token)
   136  		req.Header.Add("Accept", "application/json")
   137  		req.Header.Add("Content-Type", "application/json")
   138  
   139  		// Resubmit
   140  		res, err = jc.client.Do(req)
   141  		if err != nil {
   142  			return samlAssertion, errors.Wrap(err, "error submitting MFA login form")
   143  		}
   144  	}
   145  
   146  	// Check if our auth was successful
   147  	if res.StatusCode == 200 {
   148  		// Grab the body from the response that has the redirect in it.
   149  		reDirBody, err := ioutil.ReadAll(res.Body)
   150  
   151  		// Unmarshall the body to get the redirect address
   152  		var jcrd = new(JCRedirect)
   153  		err = json.Unmarshal(reDirBody, &jcrd)
   154  		if err != nil {
   155  			log.Fatalf("Error unmarshalling redirectTo response! %v", err)
   156  		}
   157  
   158  		// Send the final GET for our SAML response
   159  		res, err = jc.client.Get(jcrd.Address)
   160  		if err != nil {
   161  			return samlAssertion, errors.Wrap(err, "error submitting request for SAML value")
   162  		}
   163  
   164  		//try to extract SAMLResponse
   165  		doc, err := goquery.NewDocumentFromReader(res.Body)
   166  		if err != nil {
   167  			return samlAssertion, errors.Wrap(err, "error parsing document")
   168  		}
   169  
   170  		doc.Find("input").Each(func(i int, s *goquery.Selection) {
   171  			name, ok := s.Attr("name")
   172  			if !ok {
   173  				log.Fatalf("unable to locate IDP authentication form submit URL")
   174  			}
   175  
   176  			if name == "SAMLResponse" {
   177  				val, ok := s.Attr("value")
   178  				if !ok {
   179  					log.Fatalf("unable to locate saml assertion value")
   180  				}
   181  				samlAssertion = val
   182  			}
   183  
   184  		})
   185  
   186  	} else {
   187  		errMsg := fmt.Sprintf("error when trying to auth, status code %d", res.StatusCode)
   188  		return samlAssertion, errors.Wrap(err, errMsg)
   189  	}
   190  
   191  	return samlAssertion, nil
   192  }