Skip to content

Commit

Permalink
Merge pull request #630 from Versent/dustinblackman-okta-multi-fido
Browse files Browse the repository at this point in the history
dustinblackman okta multi fido
  • Loading branch information
Mark Wolfe authored Mar 11, 2021
2 parents eb7bc3c + 794281d commit cd49354
Showing 1 changed file with 147 additions and 88 deletions.
235 changes: 147 additions & 88 deletions pkg/provider/okta/okta.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"time"

"github.com/PuerkitoBio/goquery"
"github.com/marshallbrekka/go-u2fhost"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
Expand Down Expand Up @@ -75,6 +76,14 @@ type VerifyRequest struct {
PassCode string `json:"passCode,omitempty"`
}

// mfaChallengeContext is used to hold MFA challenge context in a simple struct.
type mfaChallengeContext struct {
factorID string
oktaVerify string
mfaIdentifer string
challengeResponseBody string
}

// New creates a new Okta client
func New(idpAccount *cfg.IDPAccount) (*Client, error) {

Expand Down Expand Up @@ -306,41 +315,28 @@ 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)
func findMfaOption(mfa string, mfaOptions []string, startAtIdx int) int {
for idx, val := range mfaOptions {
if startAtIdx >= idx {
continue
}
}

if strings.ToUpper(oc.mfa) != "AUTO" {
for idx, val := range mfaOptions {
if strings.HasPrefix(strings.ToUpper(val), oc.mfa) {
mfaOption = idx
break
}
if strings.HasPrefix(strings.ToUpper(val), mfa) {
return idx
}
} else if len(mfaOptions) > 1 {
mfaOption = prompter.Choose("Select which MFA option to use", mfaOptions)
}
return 0
}

func getMfaChallengeContext(oc *Client, mfaOption int, resp string) (*mfaChallengeContext, error) {
stateToken := gjson.Get(resp, "stateToken").String()
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")
return nil, errors.New("unsupported mfa provider")
}

// get signature & callback
Expand All @@ -359,31 +355,64 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,

err := json.NewEncoder(verifyBody).Encode(verifyReq)
if err != nil {
return "", errors.Wrap(err, "error encoding verifyReq")
return nil, errors.Wrap(err, "error encoding verifyReq")
}

req, err := http.NewRequest("POST", oktaVerify, verifyBody)
if err != nil {
return "", errors.Wrap(err, "error building verify request")
return nil, 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")
return nil, 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 nil, errors.Wrap(err, "error retrieving body from response")
}
resp = string(body)

switch mfa := mfaIdentifer; mfa {
return &mfaChallengeContext{
factorID: factorID,
oktaVerify: oktaVerify,
mfaIdentifer: mfaIdentifer,
challengeResponseBody: string(body),
}, nil
}

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" {
mfaOption = findMfaOption(oc.mfa, mfaOptions, 0)
} else if len(mfaOptions) > 1 {
mfaOption = prompter.Choose("Select which MFA option to use", mfaOptions)
}

challengeContext, err := getMfaChallengeContext(oc, mfaOption, resp)
if err != nil {
return "", err
}

switch mfa := challengeContext.mfaIdentifer; mfa {
case IdentifierYubiMfa:
return gjson.Get(resp, "sessionToken").String(), nil
return gjson.Get(challengeContext.challengeResponseBody, "sessionToken").String(), nil
case IdentifierSmsMfa, IdentifierTotpMfa, IdentifierOktaTotpMfa, IdentifierSymantecTotpMfa:
var verifyCode = loginDetails.MFAToken
if verifyCode == "" {
Expand All @@ -396,7 +425,7 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,
return "", errors.Wrap(err, "error encoding token data")
}

req, err = http.NewRequest("POST", oktaVerify, tokenBody)
req, err := http.NewRequest("POST", challengeContext.oktaVerify, tokenBody)
if err != nil {
return "", errors.Wrap(err, "error building token post request")
}
Expand All @@ -423,31 +452,26 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,
fmt.Printf("\nWaiting for approval, please check your Okta Verify app ...")

// loop until success, error, or timeout
body := challengeContext.challengeResponseBody
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" {
if gjson.Get(body, "status").String() == "SUCCESS" {
fmt.Printf(" Approved\n\n")
return gjson.Get(string(body), "sessionToken").String(), nil
return gjson.Get(body, "sessionToken").String(), nil
}

// otherwise probably still waiting
switch gjson.Get(string(body), "factorResult").String() {
switch gjson.Get(body, "factorResult").String() {

case "WAITING":
time.Sleep(3 * time.Second)
fmt.Printf(".")
logger.Debug("Waiting for user to authorize login")
updatedContext, err := getMfaChallengeContext(oc, mfaOption, resp)
if err != nil {
return "", err
}
body = updatedContext.challengeResponseBody

case "TIMEOUT":
fmt.Printf(" Timeout\n")
Expand All @@ -466,12 +490,12 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,
}

case IdentifierDuoMfa:
duoHost := gjson.Get(resp, "_embedded.factor._embedded.verification.host").String()
duoSignature := gjson.Get(resp, "_embedded.factor._embedded.verification.signature").String()
duoHost := gjson.Get(challengeContext.challengeResponseBody, "_embedded.factor._embedded.verification.host").String()
duoSignature := gjson.Get(challengeContext.challengeResponseBody, "_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()
duoCallback := gjson.Get(challengeContext.challengeResponseBody, "_embedded.factor._embedded.verification._links.complete.href").String()

// initiate duo mfa to get sid
duoSubmitURL := fmt.Sprintf("https://%s/frame/web/v1/auth", duoHost)
Expand All @@ -485,7 +509,7 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,
duoForm.Add("screen_resolution_height", "1692")
duoForm.Add("color_depth", "24")

req, err = http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
req, err := http.NewRequest("POST", duoSubmitURL, strings.NewReader(duoForm.Encode()))
if err != nil {
return "", errors.Wrap(err, "error building authentication request")
}
Expand All @@ -495,7 +519,7 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,

req.Header.Add("Content-Type", "application/x-www-form-urlencoded")

res, err = oc.client.Do(req)
res, err := oc.client.Do(req)
if err != nil {
return "", errors.Wrap(err, "error retrieving verify response")
}
Expand Down Expand Up @@ -560,7 +584,7 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,
return "", errors.Wrap(err, "error retrieving verify response")
}

body, err = ioutil.ReadAll(res.Body)
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", errors.Wrap(err, "error retrieving body from response")
}
Expand Down Expand Up @@ -688,7 +712,7 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,

// callback to okta with cookie
oktaForm := url.Values{}
oktaForm.Add("id", factorID)
oktaForm.Add("id", challengeContext.factorID)
oktaForm.Add("stateToken", stateToken)
oktaForm.Add("sig_response", fmt.Sprintf("%s:%s", duoTxCookie, duoSiguatres[1]))

Expand All @@ -706,14 +730,14 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,

// extract okta session token

verifyReq = VerifyRequest{StateToken: stateToken}
verifyBody = new(bytes.Buffer)
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)
req, err = http.NewRequest("POST", challengeContext.oktaVerify, verifyBody)
if err != nil {
return "", errors.Wrap(err, "error building verify request")
}
Expand All @@ -735,48 +759,83 @@ func verifyMfa(oc *Client, oktaOrgHost string, loginDetails *creds.LoginDetails,
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,
return fidoWebAuthn(oc, oktaOrgHost, challengeContext, mfaOption, stateToken, mfaOptions, resp)
}

// catch all
return "", errors.New("no mfa options provided")
}

func fidoWebAuthn(oc *Client, oktaOrgHost string, challengeContext *mfaChallengeContext, mfaOption int, stateToken string, mfaOptions []string, resp string) (string, error) {

var signedAssertion *SignedAssertion
challengeResponseBody := challengeContext.challengeResponseBody
lastMfaOption := mfaOption

for {
nonce := gjson.Get(challengeResponseBody, "_embedded.factor._embedded.challenge.challenge").String()
credentialID := gjson.Get(challengeResponseBody, "_embedded.factor.profile.credentialId").String()
version := gjson.Get(challengeResponseBody, "_embedded.factor.profile.version").String()

fidoClient, err := NewFidoClient(
nonce,
oktaOrgHost,
version,
credentialID,
stateToken,
new(U2FDeviceFinder))
new(U2FDeviceFinder),
)
if err != nil {
return "", err
}

signedAssertion, err := fidoClient.ChallengeU2F()
signedAssertion, err = fidoClient.ChallengeU2F()
if err != nil {
return "", err
}
// if this error is not a bad key error we are done
if _, ok := err.(*u2fhost.BadKeyHandleError); !ok {
return "", errors.Wrap(err, "failed to perform U2F challenge")
}

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")
// check if there is another fido device and try that
nextMfaOption := findMfaOption(oc.mfa, mfaOptions, lastMfaOption)
if nextMfaOption <= lastMfaOption {
return "", errors.Wrap(err, "tried all MFA options")
}
lastMfaOption = nextMfaOption

nextChallengeContext, err := getMfaChallengeContext(oc, nextMfaOption, resp)
if err != nil {
return "", errors.Wrap(err, "get mfa challenge failed for U2F device")
}
challengeResponseBody = nextChallengeContext.challengeResponseBody
continue
}
return gjson.GetBytes(body, "sessionToken").String(), nil

break
}

// catch all
return "", errors.New("no mfa options provided")
payload, err := json.Marshal(signedAssertion)
if err != nil {
return "", err
}

webauthnCallback := gjson.Get(challengeResponseBody, "_links.next.href").String()
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
}

0 comments on commit cd49354

Please sign in to comment.