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

     1  package okta
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"html"
     7  	"io/ioutil"
     8  	"net/http"
     9  	"net/url"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/sirupsen/logrus"
    14  	"github.com/versent/saml2aws/pkg/prompter"
    15  
    16  	"github.com/PuerkitoBio/goquery"
    17  	"github.com/pkg/errors"
    18  	"github.com/tidwall/gjson"
    19  	"github.com/versent/saml2aws/pkg/cfg"
    20  	"github.com/versent/saml2aws/pkg/creds"
    21  	"github.com/versent/saml2aws/pkg/provider"
    22  
    23  	"encoding/json"
    24  )
    25  
    26  const (
    27  	IdentifierDuoMfa      = "DUO WEB"
    28  	IdentifierSmsMfa      = "OKTA SMS"
    29  	IdentifierPushMfa     = "OKTA PUSH"
    30  	IdentifierTotpMfa     = "GOOGLE TOKEN:SOFTWARE:TOTP"
    31  	IdentifierOktaTotpMfa = "OKTA TOKEN:SOFTWARE:TOTP"
    32  )
    33  
    34  var logger = logrus.WithField("provider", "okta")
    35  
    36  var (
    37  	supportedMfaOptions = map[string]string{
    38  		IdentifierDuoMfa:      "DUO MFA authentication",
    39  		IdentifierSmsMfa:      "SMS MFA authentication",
    40  		IdentifierPushMfa:     "PUSH MFA authentication",
    41  		IdentifierTotpMfa:     "TOTP MFA authentication",
    42  		IdentifierOktaTotpMfa: "Okta MFA authentication",
    43  	}
    44  )
    45  
    46  // Client is a wrapper representing a Okta SAML client
    47  type Client struct {
    48  	client *provider.HTTPClient
    49  	mfa    string
    50  }
    51  
    52  // AuthRequest represents an mfa okta request
    53  type AuthRequest struct {
    54  	Username string `json:"username"`
    55  	Password string `json:"password"`
    56  }
    57  
    58  // VerifyRequest represents an mfa verify request
    59  type VerifyRequest struct {
    60  	StateToken string `json:"stateToken"`
    61  	PassCode   string `json:"passCode,omitempty"`
    62  }
    63  
    64  // New creates a new Okta client
    65  func New(idpAccount *cfg.IDPAccount) (*Client, error) {
    66  
    67  	tr := provider.NewDefaultTransport(idpAccount.SkipVerify)
    68  
    69  	client, err := provider.NewHTTPClient(tr)
    70  	if err != nil {
    71  		return nil, errors.Wrap(err, "error building http client")
    72  	}
    73  
    74  	// assign a response validator to ensure all responses are either success or a redirect
    75  	// this is to avoid have explicit checks for every single response
    76  	client.CheckResponseStatus = provider.SuccessOrRedirectResponseValidator
    77  
    78  	return &Client{
    79  		client: client,
    80  		mfa:    idpAccount.MFA,
    81  	}, nil
    82  }
    83  
    84  // Authenticate logs into Okta and returns a SAML response
    85  func (oc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
    86  
    87  	var samlAssertion string
    88  
    89  	oktaURL, err := url.Parse(loginDetails.URL)
    90  	if err != nil {
    91  		return samlAssertion, errors.Wrap(err, "error building oktaURL")
    92  	}
    93  
    94  	oktaOrgHost := oktaURL.Host
    95  
    96  	//authenticate via okta api
    97  	authReq := AuthRequest{Username: loginDetails.Username, Password: loginDetails.Password}
    98  	authBody := new(bytes.Buffer)
    99  	err = json.NewEncoder(authBody).Encode(authReq)
   100  	if err != nil {
   101  		return samlAssertion, errors.Wrap(err, "error encoding authreq")
   102  	}
   103  
   104  	authSubmitURL := fmt.Sprintf("https://%s/api/v1/authn", oktaOrgHost)
   105  
   106  	req, err := http.NewRequest("POST", authSubmitURL, authBody)
   107  	if err != nil {
   108  		return samlAssertion, errors.Wrap(err, "error building authentication request")
   109  	}
   110  
   111  	req.Header.Add("Content-Type", "application/json")
   112  	req.Header.Add("Accept", "application/json")
   113  
   114  	res, err := oc.client.Do(req)
   115  	if err != nil {
   116  		return samlAssertion, errors.Wrap(err, "error retrieving auth response")
   117  	}
   118  
   119  	body, err := ioutil.ReadAll(res.Body)
   120  	if err != nil {
   121  		return samlAssertion, errors.Wrap(err, "error retrieving body from response")
   122  	}
   123  
   124  	resp := string(body)
   125  
   126  	authStatus := gjson.Get(resp, "status").String()
   127  	oktaSessionToken := gjson.Get(resp, "sessionToken").String()
   128  
   129  	// mfa required
   130  	if authStatus == "MFA_REQUIRED" {
   131  		oktaSessionToken, err = verifyMfa(oc, oktaOrgHost, loginDetails, resp)
   132  		if err != nil {
   133  			return samlAssertion, errors.Wrap(err, "error verifying MFA")
   134  		}
   135  	}
   136  
   137  	//now call saml endpoint
   138  	oktaSessionRedirectURL := fmt.Sprintf("https://%s/login/sessionCookieRedirect", oktaOrgHost)
   139  
   140  	req, err = http.NewRequest("GET", oktaSessionRedirectURL, nil)
   141  	if err != nil {
   142  		return samlAssertion, errors.Wrap(err, "error building authentication request")
   143  	}
   144  	q := req.URL.Query()
   145  	q.Add("checkAccountSetupComplete", "true")
   146  	q.Add("token", oktaSessionToken)
   147  	q.Add("redirectUrl", loginDetails.URL)
   148  	req.URL.RawQuery = q.Encode()
   149  
   150  	res, err = oc.client.Do(req)
   151  	if err != nil {
   152  		return samlAssertion, errors.Wrap(err, "error retrieving verify response")
   153  	}
   154  
   155  	//try to extract SAMLResponse
   156  	doc, err := goquery.NewDocumentFromResponse(res)
   157  	if err != nil {
   158  		return samlAssertion, errors.Wrap(err, "error parsing document")
   159  	}
   160  
   161  	samlAssertion, ok := doc.Find("input[name=\"SAMLResponse\"]").Attr("value")
   162  	if !ok {
   163  		return samlAssertion, errors.Wrap(err, "unable to locate saml response")
   164  	}
   165  
   166  	logger.Debug("auth complete")
   167  
   168  	return samlAssertion, nil
   169  }
   170  
   171  func parseMfaIdentifer(json string, arrayPosition int) string {
   172  	mfaProvider := gjson.Get(json, fmt.Sprintf("_embedded.factors.%d.provider", arrayPosition)).String()
   173  	factorType := strings.ToUpper(gjson.Get(json, fmt.Sprintf("_embedded.factors.%d.factorType", arrayPosition)).String())
   174  	return fmt.Sprintf("%s %s", mfaProvider, factorType)
   175  }
   176  
   177  func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails, resp string) (string, error) {
   178  
   179  	stateToken := gjson.Get(resp, "stateToken").String()
   180  
   181  	// choose an mfa option if there are multiple enabled
   182  	mfaOption := 0
   183  	var mfaOptions []string
   184  	for i := range gjson.Get(resp, "_embedded.factors").Array() {
   185  		identifier := parseMfaIdentifer(resp, i)
   186  		if val, ok := supportedMfaOptions[identifier]; ok {
   187  			mfaOptions = append(mfaOptions, val)
   188  		} else {
   189  			mfaOptions = append(mfaOptions, "UNSUPPORTED: "+identifier)
   190  		}
   191  	}
   192  
   193  	if oc.mfa != "AUTO" {
   194  		for _, val := range mfaOptions {
   195  			if strings.HasPrefix(val, oc.mfa) {
   196  				mfaOptions = []string{val}
   197  				break
   198  			}
   199  		}
   200  	}
   201  	if len(mfaOptions) > 1 {
   202  		mfaOption = prompter.Choose("Select which MFA option to use", mfaOptions)
   203  	}
   204  
   205  	factorID := gjson.Get(resp, fmt.Sprintf("_embedded.factors.%d.id", mfaOption)).String()
   206  	oktaVerify := gjson.Get(resp, fmt.Sprintf("_embedded.factors.%d._links.verify.href", mfaOption)).String()
   207  	mfaIdentifer := parseMfaIdentifer(resp, mfaOption)
   208  
   209  	logger.WithField("factorID", factorID).WithField("oktaVerify", oktaVerify).WithField("mfaIdentifer", mfaIdentifer).Debug("MFA")
   210  
   211  	if _, ok := supportedMfaOptions[mfaIdentifer]; !ok {
   212  		return "", errors.New("unsupported mfa provider")
   213  	}
   214  
   215  	// get signature & callback
   216  	verifyReq := VerifyRequest{StateToken: stateToken}
   217  	verifyBody := new(bytes.Buffer)
   218  	err := json.NewEncoder(verifyBody).Encode(verifyReq)
   219  	if err != nil {
   220  		return "", errors.Wrap(err, "error encoding verifyReq")
   221  	}
   222  
   223  	req, err := http.NewRequest("POST", oktaVerify, verifyBody)
   224  	if err != nil {
   225  		return "", errors.Wrap(err, "error building verify request")
   226  	}
   227  
   228  	req.Header.Add("Content-Type", "application/json")
   229  	req.Header.Add("Accept", "application/json")
   230  
   231  	res, err := oc.client.Do(req)
   232  	if err != nil {
   233  		return "", errors.Wrap(err, "error retrieving verify response")
   234  	}
   235  
   236  	body, err := ioutil.ReadAll(res.Body)
   237  	if err != nil {
   238  		return "", errors.Wrap(err, "error retrieving body from response")
   239  	}
   240  	resp = string(body)
   241  
   242  	switch mfa := mfaIdentifer; mfa {
   243  	case IdentifierSmsMfa, IdentifierTotpMfa, IdentifierOktaTotpMfa:
   244  		verifyCode := prompter.StringRequired("Enter verification code")
   245  		tokenReq := VerifyRequest{StateToken: stateToken, PassCode: verifyCode}
   246  		tokenBody := new(bytes.Buffer)
   247  		json.NewEncoder(tokenBody).Encode(tokenReq)
   248  
   249  		req, err = http.NewRequest("POST", oktaVerify, tokenBody)
   250  		if err != nil {
   251  			return "", errors.Wrap(err, "error building token post request")
   252  		}
   253  
   254  		req.Header.Add("Content-Type", "application/json")
   255  		req.Header.Add("Accept", "application/json")
   256  
   257  		res, err := oc.client.Do(req)
   258  		if err != nil {
   259  			return "", errors.Wrap(err, "error retrieving token post response")
   260  		}
   261  
   262  		body, err := ioutil.ReadAll(res.Body)
   263  		if err != nil {
   264  			return "", errors.Wrap(err, "error retrieving body from response")
   265  		}
   266  
   267  		resp = string(body)
   268  
   269  		return gjson.Get(resp, "sessionToken").String(), nil
   270  
   271  	case IdentifierPushMfa:
   272  
   273  		fmt.Printf("\nWaiting for approval, please check your Okta Verify app ...")
   274  
   275  		// loop until success, error, or timeout
   276  		for {
   277  
   278  			res, err = oc.client.Do(req)
   279  			if err != nil {
   280  				return "", errors.Wrap(err, "error retrieving verify response")
   281  			}
   282  
   283  			body, err = ioutil.ReadAll(res.Body)
   284  			if err != nil {
   285  				return "", errors.Wrap(err, "error retrieving body from response")
   286  			}
   287  
   288  			// on 'success' status
   289  			if gjson.Get(string(body), "status").String() == "SUCCESS" {
   290  				fmt.Printf(" Approved\n\n")
   291  				return gjson.Get(string(body), "sessionToken").String(), nil
   292  			}
   293  
   294  			// otherwise probably still waiting
   295  			switch gjson.Get(string(body), "factorResult").String() {
   296  
   297  			case "WAITING":
   298  				time.Sleep(1000)
   299  				fmt.Printf(".")
   300  				logger.Debug("Waiting for user to authorize login")
   301  
   302  			case "TIMEOUT":
   303  				fmt.Printf(" Timeout\n")
   304  				return "", errors.New("User did not accept MFA in time")
   305  
   306  			case "REJECTED":
   307  				fmt.Printf(" Rejected\n")
   308  				return "", errors.New("MFA rejected by user")
   309  
   310  			default:
   311  				fmt.Printf(" Error\n")
   312  				return "", errors.New("Unsupported response from Okta, please raise ticket with saml2aws")
   313  
   314  			}
   315  
   316  		}
   317  
   318  	case IdentifierDuoMfa:
   319  		duoHost := gjson.Get(resp, "_embedded.factor._embedded.verification.host").String()
   320  		duoSignature := gjson.Get(resp, "_embedded.factor._embedded.verification.signature").String()
   321  		duoSiguatres := strings.Split(duoSignature, ":")
   322  		//duoSignatures[0] = TX
   323  		//duoSignatures[1] = APP
   324  		duoCallback := gjson.Get(resp, "_embedded.factor._embedded.verification._links.complete.href").String()
   325  
   326  		// initiate duo mfa to get sid
   327  		duoSubmitURL := fmt.Sprintf("https://%s/frame/web/v1/auth", duoHost)
   328  
   329  		duoForm := url.Values{}
   330  		duoForm.Add("parent", fmt.Sprintf("https://%s/signin/verify/duo/web", oktaOrgHost))
   331  		duoForm.Add("java_version", "")
   332  		duoForm.Add("java_version", "")
   333  		duoForm.Add("flash_version", "")
   334  		duoForm.Add("screen_resolution_width", "3008")
   335  		duoForm.Add("screen_resolution_height", "1692")
   336  		duoForm.Add("color_depth", "24")
   337  
   338  		req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   339  		if err != nil {
   340  			return "", errors.Wrap(err, "error building authentication request")
   341  		}
   342  		q := req.URL.Query()
   343  		q.Add("tx", duoSiguatres[0])
   344  		req.URL.RawQuery = q.Encode()
   345  
   346  		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   347  
   348  		res, err = oc.client.Do(req)
   349  		if err != nil {
   350  			return "", errors.Wrap(err, "error retrieving verify response")
   351  		}
   352  
   353  		//try to extract sid
   354  		doc, err := goquery.NewDocumentFromResponse(res)
   355  		if err != nil {
   356  			return "", errors.Wrap(err, "error parsing document")
   357  		}
   358  
   359  		duoSID, ok := doc.Find("input[name=\"sid\"]").Attr("value")
   360  		if !ok {
   361  			return "", errors.Wrap(err, "unable to locate saml response")
   362  		}
   363  		duoSID = html.UnescapeString(duoSID)
   364  
   365  		//prompt for mfa type
   366  		//only supporting push or passcode for now
   367  		var token string
   368  
   369  		var duoMfaOptions = []string{
   370  			"Duo Push",
   371  			"Passcode",
   372  		}
   373  
   374  		duoMfaOption := 0
   375  
   376  		if loginDetails.DuoMFAOption == "Duo Push" {
   377  			duoMfaOption = 0
   378  		} else if loginDetails.DuoMFAOption == "Passcode" {
   379  			duoMfaOption = 1
   380  		} else {
   381  			duoMfaOption = prompter.Choose("Select a DUO MFA Option", duoMfaOptions)
   382  		}
   383  
   384  		if duoMfaOptions[duoMfaOption] == "Passcode" {
   385  			//get users DUO MFA Token
   386  			token = prompter.StringRequired("Enter passcode")
   387  		}
   388  
   389  		// send mfa auth request
   390  		duoSubmitURL = fmt.Sprintf("https://%s/frame/prompt", duoHost)
   391  
   392  		duoForm = url.Values{}
   393  		duoForm.Add("sid", duoSID)
   394  		duoForm.Add("device", "phone1")
   395  		duoForm.Add("factor", duoMfaOptions[duoMfaOption])
   396  		duoForm.Add("out_of_date", "false")
   397  		if duoMfaOptions[duoMfaOption] == "Passcode" {
   398  			duoForm.Add("passcode", token)
   399  		}
   400  
   401  		req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   402  		if err != nil {
   403  			return "", errors.Wrap(err, "error building authentication request")
   404  		}
   405  
   406  		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   407  
   408  		res, err = oc.client.Do(req)
   409  		if err != nil {
   410  			return "", errors.Wrap(err, "error retrieving verify response")
   411  		}
   412  
   413  		body, err = ioutil.ReadAll(res.Body)
   414  		if err != nil {
   415  			return "", errors.Wrap(err, "error retrieving body from response")
   416  		}
   417  
   418  		resp = string(body)
   419  
   420  		duoTxStat := gjson.Get(resp, "stat").String()
   421  		duoTxID := gjson.Get(resp, "response.txid").String()
   422  		if duoTxStat != "OK" {
   423  			return "", errors.Wrap(err, "error authenticating mfa device")
   424  		}
   425  
   426  		// get duo cookie
   427  		duoSubmitURL = fmt.Sprintf("https://%s/frame/status", duoHost)
   428  
   429  		duoForm = url.Values{}
   430  		duoForm.Add("sid", duoSID)
   431  		duoForm.Add("txid", duoTxID)
   432  
   433  		req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   434  		if err != nil {
   435  			return "", errors.Wrap(err, "error building authentication request")
   436  		}
   437  
   438  		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   439  
   440  		res, err = oc.client.Do(req)
   441  		if err != nil {
   442  			return "", errors.Wrap(err, "error retrieving verify response")
   443  		}
   444  
   445  		body, err = ioutil.ReadAll(res.Body)
   446  		if err != nil {
   447  			return "", errors.Wrap(err, "error retrieving body from response")
   448  		}
   449  
   450  		resp = string(body)
   451  
   452  		duoTxResult := gjson.Get(resp, "response.result").String()
   453  		duoResultURL := gjson.Get(resp, "response.result_url").String()
   454  
   455  		fmt.Println(gjson.Get(resp, "response.status").String())
   456  
   457  		if duoTxResult != "SUCCESS" {
   458  			//poll as this is likely a push request
   459  			for {
   460  				time.Sleep(3 * time.Second)
   461  
   462  				req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
   463  				if err != nil {
   464  					return "", errors.Wrap(err, "error building authentication request")
   465  				}
   466  
   467  				req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   468  
   469  				res, err = oc.client.Do(req)
   470  				if err != nil {
   471  					return "", errors.Wrap(err, "error retrieving verify response")
   472  				}
   473  
   474  				body, err = ioutil.ReadAll(res.Body)
   475  				if err != nil {
   476  					return "", errors.Wrap(err, "error retrieving body from response")
   477  				}
   478  
   479  				resp := string(body)
   480  
   481  				duoTxResult = gjson.Get(resp, "response.result").String()
   482  				duoResultURL = gjson.Get(resp, "response.result_url").String()
   483  
   484  				fmt.Println(gjson.Get(resp, "response.status").String())
   485  
   486  				if duoTxResult == "FAILURE" {
   487  					return "", errors.Wrap(err, "failed to authenticate device")
   488  				}
   489  
   490  				if duoTxResult == "SUCCESS" {
   491  					break
   492  				}
   493  			}
   494  		}
   495  
   496  		duoRequestURL := fmt.Sprintf("https://%s%s", duoHost, duoResultURL)
   497  		req, err = http.NewRequest("POST", duoRequestURL, strings.NewReader(duoForm.Encode()))
   498  		if err != nil {
   499  			return "", errors.Wrap(err, "error constructing request object to result url")
   500  		}
   501  
   502  		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   503  
   504  		res, err = oc.client.Do(req)
   505  		if err != nil {
   506  			return "", errors.Wrap(err, "error retrieving duo result response")
   507  		}
   508  
   509  		body, err = ioutil.ReadAll(res.Body)
   510  		if err != nil {
   511  			return "", errors.Wrap(err, "duoResultSubmit: error retrieving body from response")
   512  		}
   513  
   514  		resp := string(body)
   515  		duoTxCookie := gjson.Get(resp, "response.cookie").String()
   516  		if duoTxCookie == "" {
   517  			return "", errors.Wrap(err, "duoResultSubmit: Unable to get response.cookie")
   518  		}
   519  
   520  		// callback to okta with cookie
   521  		oktaForm := url.Values{}
   522  		oktaForm.Add("id", factorID)
   523  		oktaForm.Add("stateToken", stateToken)
   524  		oktaForm.Add("sig_response", fmt.Sprintf("%s:%s", duoTxCookie, duoSiguatres[1]))
   525  
   526  		req, err = http.NewRequest("POST", duoCallback, strings.NewReader(oktaForm.Encode()))
   527  		if err != nil {
   528  			return "", errors.Wrap(err, "error building authentication request")
   529  		}
   530  
   531  		req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
   532  
   533  		res, err = oc.client.Do(req)
   534  		if err != nil {
   535  			return "", errors.Wrap(err, "error retrieving verify response")
   536  		}
   537  
   538  		// extract okta session token
   539  
   540  		verifyReq = VerifyRequest{StateToken: stateToken}
   541  		verifyBody = new(bytes.Buffer)
   542  		json.NewEncoder(verifyBody).Encode(verifyReq)
   543  
   544  		req, err = http.NewRequest("POST", oktaVerify, verifyBody)
   545  		if err != nil {
   546  			return "", errors.Wrap(err, "error building verify request")
   547  		}
   548  
   549  		req.Header.Add("Content-Type", "application/json")
   550  		req.Header.Add("Accept", "application/json")
   551  		req.Header.Add("X-Okta-XsrfToken", "")
   552  
   553  		res, err = oc.client.Do(req)
   554  		if err != nil {
   555  			return "", errors.Wrap(err, "error retrieving verify response")
   556  		}
   557  
   558  		body, err = ioutil.ReadAll(res.Body)
   559  		if err != nil {
   560  			return "", errors.Wrap(err, "error retrieving body from response")
   561  		}
   562  
   563  		return gjson.GetBytes(body, "sessionToken").String(), nil
   564  	}
   565  
   566  	// catch all
   567  	return "", errors.New("no mfa options provided")
   568  }