Skip to content

Commit

Permalink
Merge pull request Versent#720 from logingood/logingood/add-jumpcloud…
Browse files Browse the repository at this point in the history
…-protect

Add JumpCloud Protect (PUSH) MFA support
  • Loading branch information
Mark Wolfe authored Sep 22, 2021
2 parents f3d590d + 30b40cb commit 850f8bb
Show file tree
Hide file tree
Showing 5 changed files with 284 additions and 14 deletions.
31 changes: 18 additions & 13 deletions pkg/provider/jumpcloud/jumpcloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,25 @@ import (
)

const (
jcSSOBaseURL = "https://sso.jumpcloud.com/"
xsrfURL = "https://console.jumpcloud.com/userconsole/xsrf"
authSubmitURL = "https://console.jumpcloud.com/userconsole/auth"
webauthnSubmitURL = "https://console.jumpcloud.com/userconsole/auth/webauthn"
duoAuthSubmitURL = "https://console.jumpcloud.com/userconsole/auth/duo"

IdentifierTotpMfa = "totp"
IdentifierDuoMfa = "duo"
IdentifierU2F = "webauthn"
jcSSOBaseURL = "https://sso.jumpcloud.com/"
xsrfURL = "https://console.jumpcloud.com/userconsole/xsrf"
authSubmitURL = "https://console.jumpcloud.com/userconsole/auth"
webauthnSubmitURL = "https://console.jumpcloud.com/userconsole/auth/webauthn"
duoAuthSubmitURL = "https://console.jumpcloud.com/userconsole/auth/duo"
jumpCloudProtectSubmitURL = "https://console.jumpcloud.com/userconsole/auth/push"

IdentifierTotpMfa = "totp"
IdentifierDuoMfa = "duo"
IdentifierU2F = "webauthn"
IdentifierJumpCloudProtect = "push"
)

var (
supportedMfaOptions = map[string]string{
IdentifierTotpMfa: "TOTP MFA authentication",
IdentifierDuoMfa: "DUO MFA authentication",
IdentifierU2F: "FIDO WebAuthn authentication",
IdentifierTotpMfa: "TOTP MFA authentication",
IdentifierDuoMfa: "DUO MFA authentication",
IdentifierU2F: "FIDO WebAuthn authentication",
IdentifierJumpCloudProtect: "PUSH MFA authentication (JumpCloud Protect)",
}
)

Expand Down Expand Up @@ -310,6 +313,9 @@ func (jc *Client) verifyMFA(jumpCloudOrgHost string, loginDetails *creds.LoginDe
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
return jc.client.Do(req)

case IdentifierJumpCloudProtect:
return jc.jumpCloudProtectAuth(jumpCloudProtectSubmitURL, xsrfToken)
case IdentifierDuoMfa:
// Get Duo config
req, err := http.NewRequest("GET", duoAuthSubmitURL, nil)
Expand Down Expand Up @@ -577,7 +583,6 @@ func (jc *Client) verifyMFA(jumpCloudOrgHost string, loginDetails *creds.LoginDe
}

func (jc *Client) getUserOption(body []byte) (string, error) {
// data =>map[factors:[map[status:available type:totp] map[status:available type:webauthn]] message:MFA required.]
mfaConfigData := gjson.GetBytes(body, "factors")
if mfaConfigData.Index == 0 {
log.Fatalln("Mfa Config option not found")
Expand Down
109 changes: 109 additions & 0 deletions pkg/provider/jumpcloud/jumpcloud_protect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package jumpcloud

import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"path"
"time"

"github.com/pkg/errors"
)

type JumpCloudPushResponse struct {
ID string `json:"id"`
ExpiresAt time.Time `json:"expiresAt"`
InitiatedAt time.Time `json:"initiatedAt"`
Status string `json:"status"`
UserId string `json:"userId"`
}

func (jc *Client) jumpCloudProtectAuth(submitUrl string, xsrfToken string) (*http.Response, error) {
jumpCloudParsedURL, err := url.Parse(submitUrl)
if err != nil {
return nil, errors.Wrap(err, fmt.Sprintf("unable to parse submit url, url=%s", jumpCloudProtectSubmitURL))
}

req, err := http.NewRequest("POST", jumpCloudParsedURL.String(), emptyJSONIOReader())
if err != nil {
return nil, errors.Wrap(err, "error building jumpcloud protect auth request")
}
ensureHeaders(xsrfToken, req)

res, err := jc.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "error retrieving JumpCloud PUSH payload")
}
defer res.Body.Close()

if res.StatusCode != 200 {
return nil, errors.New("error retrieving JumpCloud PUSH payload, non 200 status returned")
}

jpResp, err := ioutil.ReadAll(res.Body)
if err != nil {
return nil, errors.Wrap(err, "error retrieving JumpCloud PUSH payload")
}

jp := JumpCloudPushResponse{}
if err := json.Unmarshal(jpResp, &jp); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal JumpCloud PUSH payload to struct")
}

jumpCloudParsedURL.Path = path.Join(jumpCloudParsedURL.Path, jp.ID)
req, err = http.NewRequest("GET", jumpCloudParsedURL.String(), nil)
ensureHeaders(xsrfToken, req)

if err != nil {
return nil, errors.Wrap(err, "failed to build JumpCoud PUSH polling request")
}

// Stay in the loop until we get something else other than "pending".
// jp.Status can be:
// * accepted
// * expired
// * denied

for jp.Status == "pending" {
if time.Now().UTC().After(jp.ExpiresAt) {
return nil, errors.New("the session is expired try again")
}

resp, err := jc.client.Do(req)
if err != nil {
return nil, errors.Wrap(err, "error retrieving verify response")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New(fmt.Sprintf("received non 200 http code, http code = %d", resp.StatusCode))
}

bytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "failed to unmarshal JumpCloud PUSH body")
}

if err := json.Unmarshal(bytes, &jp); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal poll result json into struct")
}

// sleep for 500ms before next request
time.Sleep(500 * time.Millisecond)
}

if jp.Status != "accepted" {
return nil, errors.New(fmt.Sprintf("didn't receive accepted, status=%s", jp.Status))
}

jumpCloudParsedURL.Path = path.Join(jumpCloudParsedURL.Path, "login")
req, err = http.NewRequest("POST", jumpCloudParsedURL.String(), emptyJSONIOReader())
if err != nil {
return nil, errors.Wrap(err, "failed to build JumpCoud login request")
}

ensureHeaders(xsrfToken, req)
return jc.client.Do(req)
}
139 changes: 139 additions & 0 deletions pkg/provider/jumpcloud/jumpcloud_protect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package jumpcloud

import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/versent/saml2aws/v2/pkg/cfg"
)

type test struct {
code int
err string
testCase string
}

func Test_jumpCloudProtectAuth(t *testing.T) {
jumpCloudPushResp := JumpCloudPushResponse{
ExpiresAt: time.Now().Add(1 * time.Minute).UTC(),
ID: "foo",
}

pendingCnt := 1
maxPending := 2
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// using token here as a clue to mock responses
switch token := r.Header.Get("X-Xsrftoken"); {
case token == "happy":
switch r.URL.Path {
case "/":
returnResp(t, "pending", 200, &jumpCloudPushResp, w)
case fmt.Sprintf("/%s", jumpCloudPushResp.ID):
returnResp(t, "accepted", 200, &jumpCloudPushResp, w)
case fmt.Sprintf("/%s/login", jumpCloudPushResp.ID):
_, err := w.Write([]byte(`{}`))
require.Nil(t, err)
}

case token == "loop twice until accepted":
switch r.URL.Path {
case "/":
returnResp(t, "pending", 200, &jumpCloudPushResp, w)
case fmt.Sprintf("/%s", jumpCloudPushResp.ID):
pendingCnt += 1
if pendingCnt == maxPending {
returnResp(t, "accepted", 200, &jumpCloudPushResp, w)
} else {
returnResp(t, "pending", 200, &jumpCloudPushResp, w)
}
case fmt.Sprintf("/%s/login", jumpCloudPushResp.ID):
_, err := w.Write([]byte(`{}`))
require.Nil(t, err)
}

case token == "payload error":
w.WriteHeader(http.StatusBadRequest)
_, err := w.Write([]byte(`{}`))
require.Nil(t, err)

case token == "received expired":
switch r.URL.Path {
case "/":
jumpCloudPushResp.Status = "pending"
bytes, err := json.Marshal(&jumpCloudPushResp)
require.Nil(t, err)
_, _ = w.Write(bytes)
case fmt.Sprintf("/%s", jumpCloudPushResp.ID):
returnResp(t, "expired", http.StatusOK, &jumpCloudPushResp, w)
}

case token == "received denied":
switch r.URL.Path {
case "/":
jumpCloudPushResp.Status = "pending"
bytes, err := json.Marshal(&jumpCloudPushResp)
require.Nil(t, err)
_, _ = w.Write(bytes)
case fmt.Sprintf("/%s", jumpCloudPushResp.ID):
returnResp(t, "denied", http.StatusUnauthorized, &jumpCloudPushResp, w)
}

case token == "login error":
switch r.URL.Path {
case "/":
jumpCloudPushResp.Status = "pending"
bytes, err := json.Marshal(&jumpCloudPushResp)
require.Nil(t, err)
_, _ = w.Write(bytes)
case fmt.Sprintf("/%s", jumpCloudPushResp.ID):
jumpCloudPushResp.Status = "accepted"
bytes, err := json.Marshal(&jumpCloudPushResp)
require.Nil(t, err)
_, _ = w.Write(bytes)
case fmt.Sprintf("/%s/login", jumpCloudPushResp.ID):
w.WriteHeader(http.StatusInternalServerError)
}
}
}))
defer ts.Close()

client, err := New(&cfg.IDPAccount{Provider: "JumpCloud", MFA: "PUSH"})
require.Nil(t, err)

tests := []test{
{testCase: "happy", code: http.StatusOK},
{testCase: "loop twice until accepted", code: http.StatusOK},
{testCase: "login error", code: http.StatusInternalServerError},
{testCase: "payload error", code: http.StatusInternalServerError, err: "error retrieving JumpCloud PUSH payload, non 200 status returned"},
{testCase: "received expired", err: "didn't receive accepted, status=expired"},
{testCase: "received denied", err: "received non 200 http code, http code = 401"},
}

for _, test := range tests {
t.Run(test.testCase, func(t *testing.T) {
resp, err := client.jumpCloudProtectAuth(ts.URL, test.testCase)
if test.err == "" {
require.Nil(t, err)
require.Equal(t, test.code, resp.StatusCode)
} else {
require.EqualError(t, err, test.err)
}
})
}

require.Equal(t, pendingCnt, maxPending)
}

func returnResp(t *testing.T, status string, statusCode int, j *JumpCloudPushResponse, w http.ResponseWriter) {
j.Status = status
bytes, err := json.Marshal(j)
require.Nil(t, err)
w.WriteHeader(statusCode)
_, err = w.Write(bytes)
require.Nil(t, err)
}
17 changes: 17 additions & 0 deletions pkg/provider/jumpcloud/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package jumpcloud

import (
"bytes"
"io"
"net/http"
)

func ensureHeaders(xsrfToken string, req *http.Request) {
req.Header.Add("X-Xsrftoken", xsrfToken)
req.Header.Add("Accept", "application/json")
req.Header.Add("Content-Type", "application/json")
}

func emptyJSONIOReader() io.Reader {
return bytes.NewReader([]byte(`{}`))
}
2 changes: 1 addition & 1 deletion saml2aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ var MFAsByProvider = ProviderList{
"ADFS2": []string{"Auto", "RSA"}, // nothing automatic about ADFS 2.x
"Ping": []string{"Auto"}, // automatically detects PingID
"PingOne": []string{"Auto"}, // automatically detects PingID
"JumpCloud": []string{"Auto", "TOTP", "WEBAUTHN", "DUO"},
"JumpCloud": []string{"Auto", "TOTP", "WEBAUTHN", "DUO", "PUSH"},
"Okta": []string{"Auto", "PUSH", "DUO", "SMS", "TOTP", "OKTA", "FIDO", "YUBICO TOKEN:HARDWARE"}, // automatically detects DUO, SMS, ToTP, and FIDO
"OneLogin": []string{"Auto", "OLP", "SMS", "TOTP", "YUBIKEY"}, // automatically detects OneLogin Protect, SMS and ToTP
"KeyCloak": []string{"Auto"}, // automatically detects ToTP
Expand Down

0 comments on commit 850f8bb

Please sign in to comment.