pkg/provider/okta/okta.go (584 lines of code) (raw):
package okta
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"io/ioutil"
"log"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/aliyun/saml2alibabacloud/pkg/cfg"
"github.com/aliyun/saml2alibabacloud/pkg/creds"
"github.com/aliyun/saml2alibabacloud/pkg/page"
"github.com/aliyun/saml2alibabacloud/pkg/prompter"
"github.com/aliyun/saml2alibabacloud/pkg/provider"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
const (
IdentifierDuoMfa = "DUO WEB"
IdentifierSmsMfa = "OKTA SMS"
IdentifierPushMfa = "OKTA PUSH"
IdentifierTotpMfa = "GOOGLE TOKEN:SOFTWARE:TOTP"
IdentifierOktaTotpMfa = "OKTA TOKEN:SOFTWARE:TOTP"
IdentifierSymantecTotpMfa = "SYMANTEC TOKEN"
IdentifierFIDOWebAuthn = "FIDO WEBAUTHN"
IdentifierYubiMfa = "YUBICO TOKEN:HARDWARE"
)
var logger = logrus.WithField("provider", "okta")
var (
supportedMfaOptions = map[string]string{
IdentifierDuoMfa: "DUO MFA authentication",
IdentifierSmsMfa: "SMS MFA authentication",
IdentifierPushMfa: "PUSH MFA authentication",
IdentifierTotpMfa: "TOTP MFA authentication",
IdentifierOktaTotpMfa: "Okta MFA authentication",
IdentifierSymantecTotpMfa: "Symantec VIP MFA authentication",
IdentifierFIDOWebAuthn: "FIDO WebAuthn MFA authentication",
IdentifierYubiMfa: "YUBICO TOKEN:HARDWARE",
}
)
// Client is a wrapper representing a Okta SAML client
type Client struct {
client *provider.HTTPClient
mfa string
}
// AuthRequest represents an mfa okta request
type AuthRequest struct {
Username string `json:"username"`
Password string `json:"password"`
StateToken string `json:"stateToken,omitempty"`
}
// VerifyRequest represents an mfa verify request
type VerifyRequest struct {
StateToken string `json:"stateToken"`
PassCode string `json:"passCode,omitempty"`
}
// New creates a new Okta client
func New(idpAccount *cfg.IDPAccount) (*Client, error) {
tr := provider.NewDefaultTransport(idpAccount.SkipVerify)
client, err := provider.NewHTTPClient(tr, provider.BuildHttpClientOpts(idpAccount))
if err != nil {
return nil, errors.Wrap(err, "error building http client")
}
// assign a response validator to ensure all responses are either success or a redirect
// this is to avoid have explicit checks for every single response
client.CheckResponseStatus = provider.SuccessOrRedirectResponseValidator
return &Client{
client: client,
mfa: idpAccount.MFA,
}, nil
}
type ctxKey string
// Authenticate logs into Okta and returns a SAML response
func (oc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
oktaURL, err := url.Parse(loginDetails.URL)
if err != nil {
return "", errors.Wrap(err, "error building oktaURL")
}
oktaOrgHost := oktaURL.Host
//authenticate via okta api
authReq := AuthRequest{Username: loginDetails.Username, Password: loginDetails.Password}
if loginDetails.StateToken != "" {
authReq = AuthRequest{StateToken: loginDetails.StateToken}
}
authBody := new(bytes.Buffer)
err = json.NewEncoder(authBody).Encode(authReq)
if err != nil {
return "", errors.Wrap(err, "error encoding authreq")
}
authSubmitURL := fmt.Sprintf("https://%s/api/v1/authn", oktaOrgHost)
req, err := http.NewRequest("POST", authSubmitURL, authBody)
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
res, err := oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving auth response")
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp := string(body)
authStatus := gjson.Get(resp, "status").String()
oktaSessionToken := gjson.Get(resp, "sessionToken").String()
// mfa required
if authStatus == "MFA_REQUIRED" {
oktaSessionToken, err = verifyMfa(oc, oktaOrgHost, loginDetails, resp)
if err != nil {
return "", errors.Wrap(err, "error verifying MFA")
}
}
//now call saml endpoint
oktaSessionRedirectURL := fmt.Sprintf("https://%s/login/sessionCookieRedirect", oktaOrgHost)
req, err = http.NewRequest("GET", oktaSessionRedirectURL, nil)
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
q := req.URL.Query()
q.Add("checkAccountSetupComplete", "true")
q.Add("token", oktaSessionToken)
q.Add("redirectUrl", loginDetails.URL)
req.URL.RawQuery = q.Encode()
ctx := context.WithValue(context.Background(), ctxKey("login"), loginDetails)
return oc.follow(ctx, req, loginDetails)
}
func (oc *Client) follow(ctx context.Context, req *http.Request, loginDetails *creds.LoginDetails) (string, error) {
res, err := oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error following")
}
doc, err := goquery.NewDocumentFromResponse(res)
if err != nil {
return "", errors.Wrap(err, "failed to build document from response")
}
var handler func(context.Context, *goquery.Document) (context.Context, *http.Request, error)
if docIsFormRedirectToAlibabaCloud(doc) {
logger.WithField("type", "saml-response").Debug("doc detect")
if samlResponse, ok := extractSAMLResponse(doc); ok {
decodedSamlResponse, err := base64.StdEncoding.DecodeString(samlResponse)
if err != nil {
return "", errors.Wrap(err, "failed to decode saml-response")
}
logger.WithField("type", "saml-response").WithField("saml-response", string(decodedSamlResponse)).Debug("doc detect")
return samlResponse, nil
}
} else if docIsFormSamlRequest(doc) {
logger.WithField("type", "saml-request").Debug("doc detect")
handler = oc.handleFormRedirect
} else if docIsFormResume(doc) {
logger.WithField("type", "resume").Debug("doc detect")
handler = oc.handleFormRedirect
} else if docIsFormSamlResponse(doc) {
logger.WithField("type", "saml-response").Debug("doc detect")
handler = oc.handleFormRedirect
} else {
req, err = http.NewRequest("GET", loginDetails.URL, nil)
if err != nil {
return "", errors.Wrap(err, "error building app request")
}
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving app response")
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
stateToken, err := getStateTokenFromOktaPageBody(string(body))
if err != nil {
return "", errors.Wrap(err, "error retrieving saml response")
}
loginDetails.StateToken = stateToken
return oc.Authenticate(loginDetails)
}
if handler == nil {
html, _ := doc.Selection.Html()
logger.WithField("doc", html).Debug("Unknown document type")
return "", fmt.Errorf("Unknown document type")
}
ctx, req, err = handler(ctx, doc)
if err != nil {
return "", err
}
return oc.follow(ctx, req, loginDetails)
}
func getStateTokenFromOktaPageBody(responseBody string) (string, error) {
re := regexp.MustCompile("var stateToken = '(.*)';")
match := re.FindStringSubmatch(responseBody)
if len(match) < 2 {
return "", errors.New("cannot find state token")
}
return strings.Replace(match[1], `\x2D`, "-", -1), nil
}
func parseMfaIdentifer(json string, arrayPosition int) string {
mfaProvider := gjson.Get(json, fmt.Sprintf("_embedded.factors.%d.provider", arrayPosition)).String()
factorType := strings.ToUpper(gjson.Get(json, fmt.Sprintf("_embedded.factors.%d.factorType", arrayPosition)).String())
return fmt.Sprintf("%s %s", mfaProvider, factorType)
}
func (oc *Client) handleFormRedirect(ctx context.Context, doc *goquery.Document) (context.Context, *http.Request, error) {
form, err := page.NewFormFromDocument(doc, "")
if err != nil {
return ctx, nil, errors.Wrap(err, "error extracting redirect form")
}
req, err := form.BuildRequest()
return ctx, req, err
}
func docIsFormSamlRequest(doc *goquery.Document) bool {
return doc.Find("input[name=\"SAMLRequest\"]").Size() == 1
}
func docIsFormSamlResponse(doc *goquery.Document) bool {
return doc.Find("input[name=\"SAMLResponse\"]").Size() == 1
}
func docIsFormResume(doc *goquery.Document) bool {
return doc.Find("input[name=\"RelayState\"]").Size() == 1
}
func docIsFormRedirectToAlibabaCloud(doc *goquery.Document) bool {
urls := []string{"form[action=\"https://signin.aliyun.com/saml-role/sso\"]",
"form[action=\"https://signin.alibabacloud.com/saml-role/sso\"]",
}
for _, value := range urls {
if doc.Find(value).Size() > 0 {
return true
}
}
return false
}
func extractSAMLResponse(doc *goquery.Document) (v string, ok bool) {
return doc.Find("input[name=\"SAMLResponse\"]").Attr("value")
}
func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails, resp string) (string, error) {
stateToken := gjson.Get(resp, "stateToken").String()
// choose an mfa option if there are multiple enabled
mfaOption := 0
var mfaOptions []string
for i := range gjson.Get(resp, "_embedded.factors").Array() {
identifier := parseMfaIdentifer(resp, i)
if val, ok := supportedMfaOptions[identifier]; ok {
mfaOptions = append(mfaOptions, val)
} else {
mfaOptions = append(mfaOptions, "UNSUPPORTED: "+identifier)
}
}
if strings.ToUpper(oc.mfa) != "AUTO" {
for idx, val := range mfaOptions {
if strings.HasPrefix(strings.ToUpper(val), oc.mfa) {
mfaOption = idx
break
}
}
} else if len(mfaOptions) > 1 {
mfaOption = prompter.Choose("Select which MFA option to use", mfaOptions)
}
factorID := gjson.Get(resp, fmt.Sprintf("_embedded.factors.%d.id", mfaOption)).String()
oktaVerify := gjson.Get(resp, fmt.Sprintf("_embedded.factors.%d._links.verify.href", mfaOption)).String()
mfaIdentifer := parseMfaIdentifer(resp, mfaOption)
logger.WithField("factorID", factorID).WithField("oktaVerify", oktaVerify).WithField("mfaIdentifer", mfaIdentifer).Debug("MFA")
if _, ok := supportedMfaOptions[mfaIdentifer]; !ok {
return "", errors.New("unsupported mfa provider")
}
// get signature & callback
verifyReq := VerifyRequest{StateToken: stateToken}
verifyBody := new(bytes.Buffer)
// Login flow is different for YubiKeys ( of course )
// https://developer.okta.com/docs/reference/api/factors/#request-example-for-verify-yubikey-factor
// verifyBody needs to be a json document with the OTP from the yubikey in it.
// yay
switch mfa := mfaIdentifer; mfa {
case IdentifierYubiMfa:
verifyCode := prompter.Password("Press the button on your yubikey")
verifyReq.PassCode = verifyCode
}
err := json.NewEncoder(verifyBody).Encode(verifyReq)
if err != nil {
return "", errors.Wrap(err, "error encoding verifyReq")
}
req, err := http.NewRequest("POST", oktaVerify, verifyBody)
if err != nil {
return "", errors.Wrap(err, "error building verify request")
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
res, err := oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp = string(body)
switch mfa := mfaIdentifer; mfa {
case IdentifierYubiMfa:
return gjson.Get(resp, "sessionToken").String(), nil
case IdentifierSmsMfa, IdentifierTotpMfa, IdentifierOktaTotpMfa, IdentifierSymantecTotpMfa:
var verifyCode = loginDetails.MFAToken
if verifyCode == "" {
verifyCode = prompter.StringRequired("Enter verification code")
}
tokenReq := VerifyRequest{StateToken: stateToken, PassCode: verifyCode}
tokenBody := new(bytes.Buffer)
err = json.NewEncoder(tokenBody).Encode(tokenReq)
if err != nil {
return "", errors.Wrap(err, "error encoding token data")
}
req, err = http.NewRequest("POST", oktaVerify, tokenBody)
if err != nil {
return "", errors.Wrap(err, "error building token post request")
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
res, err := oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving token post response")
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp = string(body)
return gjson.Get(resp, "sessionToken").String(), nil
case IdentifierPushMfa:
fmt.Printf("\nWaiting for approval, please check your Okta Verify app ...")
// loop until success, error, or timeout
for {
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
// on 'success' status
if gjson.Get(string(body), "status").String() == "SUCCESS" {
fmt.Printf(" Approved\n\n")
return gjson.Get(string(body), "sessionToken").String(), nil
}
// otherwise probably still waiting
switch gjson.Get(string(body), "factorResult").String() {
case "WAITING":
time.Sleep(3 * time.Second)
fmt.Printf(".")
logger.Debug("Waiting for user to authorize login")
case "TIMEOUT":
fmt.Printf(" Timeout\n")
return "", errors.New("User did not accept MFA in time")
case "REJECTED":
fmt.Printf(" Rejected\n")
return "", errors.New("MFA rejected by user")
default:
fmt.Printf(" Error\n")
return "", errors.New("Unsupported response from Okta, please raise ticket with saml2alibabacloud")
}
}
case IdentifierDuoMfa:
duoHost := gjson.Get(resp, "_embedded.factor._embedded.verification.host").String()
duoSignature := gjson.Get(resp, "_embedded.factor._embedded.verification.signature").String()
duoSiguatres := strings.Split(duoSignature, ":")
//duoSignatures[0] = TX
//duoSignatures[1] = APP
duoCallback := gjson.Get(resp, "_embedded.factor._embedded.verification._links.complete.href").String()
// initiate duo mfa to get sid
duoSubmitURL := fmt.Sprintf("https://%s/frame/web/v1/auth", duoHost)
duoForm := url.Values{}
duoForm.Add("parent", fmt.Sprintf("https://%s/signin/verify/duo/web", oktaOrgHost))
duoForm.Add("java_version", "")
duoForm.Add("java_version", "")
duoForm.Add("flash_version", "")
duoForm.Add("screen_resolution_width", "3008")
duoForm.Add("screen_resolution_height", "1692")
duoForm.Add("color_depth", "24")
req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
q := req.URL.Query()
q.Add("tx", duoSiguatres[0])
req.URL.RawQuery = q.Encode()
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
//try to extract sid
doc, err := goquery.NewDocumentFromResponse(res)
if err != nil {
return "", errors.Wrap(err, "error parsing document")
}
duoSID, ok := doc.Find("input[name=\"sid\"]").Attr("value")
if !ok {
return "", errors.Wrap(err, "unable to locate saml response")
}
duoSID = html.UnescapeString(duoSID)
//prompt for mfa type
//only supporting push or passcode for now
var token string
var duoMfaOptions = []string{
"Duo Push",
"Passcode",
}
duoMfaOption := 0
if loginDetails.DuoMFAOption == "Duo Push" {
duoMfaOption = 0
} else if loginDetails.DuoMFAOption == "Passcode" {
duoMfaOption = 1
} else {
duoMfaOption = prompter.Choose("Select a DUO MFA Option", duoMfaOptions)
}
if duoMfaOptions[duoMfaOption] == "Passcode" {
//get users DUO MFA Token
token = prompter.StringRequired("Enter passcode")
}
// send mfa auth request
duoSubmitURL = fmt.Sprintf("https://%s/frame/prompt", duoHost)
duoForm = url.Values{}
duoForm.Add("sid", duoSID)
duoForm.Add("device", "phone1")
duoForm.Add("factor", duoMfaOptions[duoMfaOption])
duoForm.Add("out_of_date", "false")
if duoMfaOptions[duoMfaOption] == "Passcode" {
duoForm.Add("passcode", token)
}
req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp = string(body)
duoTxStat := gjson.Get(resp, "stat").String()
duoTxID := gjson.Get(resp, "response.txid").String()
if duoTxStat != "OK" {
return "", errors.New("error authenticating mfa device")
}
// get duo cookie
duoSubmitURL = fmt.Sprintf("https://%s/frame/status", duoHost)
duoForm = url.Values{}
duoForm.Add("sid", duoSID)
duoForm.Add("txid", duoTxID)
req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp = string(body)
duoTxResult := gjson.Get(resp, "response.result").String()
duoResultURL := gjson.Get(resp, "response.result_url").String()
newSID := gjson.Get(resp, "response.sid").String()
if newSID != "" {
duoSID = newSID
}
log.Println(gjson.Get(resp, "response.status").String())
if duoTxResult != "SUCCESS" {
//poll as this is likely a push request
for {
time.Sleep(3 * time.Second)
req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
resp := string(body)
duoTxResult = gjson.Get(resp, "response.result").String()
duoResultURL = gjson.Get(resp, "response.result_url").String()
newSID = gjson.Get(resp, "response.sid").String()
if newSID != "" {
duoSID = newSID
}
log.Println(gjson.Get(resp, "response.status").String())
if duoTxResult == "FAILURE" {
return "", errors.Wrap(err, "failed to authenticate device")
}
if duoTxResult == "SUCCESS" {
break
}
}
}
duoRequestURL := fmt.Sprintf("https://%s%s", duoHost, duoResultURL)
duoForm = url.Values{}
duoForm.Add("sid", duoSID)
req, err = http.NewRequest("POST", duoRequestURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error constructing request object to result url")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving duo result response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "duoResultSubmit: error retrieving body from response")
}
resp := string(body)
duoTxStat = gjson.Get(resp, "stat").String()
if duoTxStat != "OK" {
message := gjson.Get(resp, "message").String()
return "", fmt.Errorf("duoResultSubmit: %s %s", duoTxStat, message)
}
duoTxCookie := gjson.Get(resp, "response.cookie").String()
if duoTxCookie == "" {
return "", errors.New("duoResultSubmit: Unable to get response.cookie")
}
// callback to okta with cookie
oktaForm := url.Values{}
oktaForm.Add("id", factorID)
oktaForm.Add("stateToken", stateToken)
oktaForm.Add("sig_response", fmt.Sprintf("%s:%s", duoTxCookie, duoSiguatres[1]))
req, err = http.NewRequest("POST", duoCallback, strings.NewReader(oktaForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
_, err = oc.client.Do(req) // TODO: check result
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
// extract okta session token
verifyReq = VerifyRequest{StateToken: stateToken}
verifyBody = new(bytes.Buffer)
err = json.NewEncoder(verifyBody).Encode(verifyReq)
if err != nil {
return "", errors.Wrap(err, "error encoding verify request")
}
req, err = http.NewRequest("POST", oktaVerify, verifyBody)
if err != nil {
return "", errors.Wrap(err, "error building verify request")
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
req.Header.Add("X-Okta-XsrfToken", "")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
return gjson.GetBytes(body, "sessionToken").String(), nil
case IdentifierFIDOWebAuthn:
nonce := gjson.Get(resp, "_embedded.factor._embedded.challenge.challenge").String()
credentialID := gjson.Get(resp, "_embedded.factor.profile.credentialId").String()
version := gjson.Get(resp, "_embedded.factor.profile.version").String()
appID := oktaOrgHost
webauthnCallback := gjson.Get(resp, "_links.next.href").String()
fidoClient, err := NewFidoClient(nonce,
appID,
version,
credentialID,
stateToken,
new(U2FDeviceFinder))
if err != nil {
return "", err
}
signedAssertion, err := fidoClient.ChallengeU2F()
if err != nil {
return "", err
}
payload, err := json.Marshal(signedAssertion)
if err != nil {
return "", err
}
req, err = http.NewRequest("POST", webauthnCallback, strings.NewReader(string(payload)))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
res, err = oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
body, err = ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
return gjson.GetBytes(body, "sessionToken").String(), nil
}
// catch all
return "", errors.New("no mfa options provided")
}