Repository: Versent/saml2aws
Pull request #398 (2020-01-11 23:47:41)

Adds Google u2f support
Update the u2f library to the upstream version, updates the mocks and
updates the Google provider to include u2f support. 

Addresses #238

mothershipper: ping @alsmola to check Okta for breakage -- it looks like your changes were merged upstream https://github.com/marshallbrekka/go-u2fhost/pull/6

Google u2f didn't seem to work using your fork, but passed as soon as I changed the import -- hoping it's not the case that this breaks Okta's webauthn support :/ 
mothershipper: Oh, my bad, didn't see your PR before opening this one -- I'll rebase this on top of yours later.
alsmola: This should be fine - I have a PR to go back to the source of go-u2fhost anwyays: https://github.com/Versent/saml2aws/pull/396

Patch:
diff --git a/pkg/provider/googleapps/googleapps.go b/pkg/provider/googleapps/googleapps.go
index af8ae2a..ebcb7eb 100644
--- a/pkg/provider/googleapps/googleapps.go
+++ b/pkg/provider/googleapps/googleapps.go
@@ -2,6 +2,7 @@ package googleapps
 
 import (
 	"bytes"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -126,11 +127,11 @@ func (kc *Client) loadFirstPage(loginDetails *creds.LoginDetails) (string, url.V
 	}
 
 	postForm := url.Values{
-		"bgresponse":      []string{"js_disabled"},
-		"checkConnection": []string{""},
-		"checkedDomains":  []string{"youtube"},
-		"continue":        []string{authForm.Get("continue")},
-		"gxf":             []string{authForm.Get("gxf")},
+		"bgresponse":               []string{"js_disabled"},
+		"checkConnection":          []string{""},
+		"checkedDomains":           []string{"youtube"},
+		"continue":                 []string{authForm.Get("continue")},
+		"gxf":                      []string{authForm.Get("gxf")},
 		"identifier-captcha-input": []string{""},
 		"identifiertoken":          []string{""},
 		"identifiertoken_audio":    []string{""},
@@ -247,6 +248,24 @@ func (kc *Client) loadChallengePage(submitURL string, referer string, authForm u
 
 			return kc.loadResponsePage(u.String(), submitURL, responseForm)
 
+		case strings.Contains(secondActionURL, "challenge/sk/"): // handle u2f challenge
+			facet := u.Scheme + "://" + u.Host
+			challengeNonce := responseForm.Get("id-challenge")
+			appId, data := extractKeyHandles(doc, challengeNonce)
+			u2fClient, err := NewU2FClient(challengeNonce, appId, facet, data[0], &U2FDeviceFinder{})
+			if err != nil {
+				return nil, errors.Wrap(err, "Failed to prompt for second factor.")
+			}
+
+			response, err := u2fClient.ChallengeU2F()
+			if err != nil {
+				return nil, errors.Wrap(err, "Second factor failed.")
+			}
+
+			responseForm.Set("id-assertion", response)
+			responseForm.Set("TrustDevice", "on")
+
+			return kc.loadResponsePage(u.String(), submitURL, responseForm)
 		case strings.Contains(secondActionURL, "challenge/az/"): // handle phone challenge
 
 			dataAttrs := extractDataAttributes(doc, "div[data-context]", []string{"data-context", "data-gapi-url", "data-tx-id", "data-api-key", "data-tx-lifetime"})
@@ -484,3 +503,85 @@ func extractDataAttributes(doc *goquery.Document, query string, attrsToSelect []
 
 	return dataAttrs
 }
+
+func extractKeyHandles(doc *goquery.Document, challengeTxt string) (string, []string) {
+	appId := ""
+	keyHandles := []string{}
+	result := map[string]interface{}{}
+	doc.Find("div[jsname=C0oDBd]").Each(func(_ int, sel *goquery.Selection) {
+		val, ok := sel.Attr("data-challenge-ui")
+		if ok {
+			firstIdx := strings.Index(val, "{")
+			lastIdx := strings.LastIndex(val, "}")
+			obj := []byte(val[firstIdx : lastIdx+1])
+			json.Unmarshal(obj, &result)
+
+			// Key handles
+			for _, val := range result {
+				list, ok := val.([]interface{})
+				if !ok {
+					continue
+				}
+				tmpId, stringList := filterKeyHandleList(list, challengeTxt)
+				if tmpId != "" {
+					appId = tmpId
+				}
+				if len(stringList) != 0 {
+					keyHandles = append(keyHandles, stringList...)
+				}
+			}
+		}
+	})
+	return appId, keyHandles
+}
+
+func filterKeyHandleList(list []interface{}, challengeTxt string) (string, []string) {
+	appId := ""
+	newList := []string{}
+	for _, entry := range list {
+		if entry == nil {
+			continue
+		}
+		moreList, ok := entry.([]interface{})
+		if ok {
+			id, l := filterKeyHandleList(moreList, challengeTxt)
+			if id != "" {
+				appId = id
+			}
+			newList = append(newList, l...)
+			continue
+		}
+		str, ok := entry.(string)
+		if !ok {
+			continue
+		}
+		if appId == "" {
+			appId = isAppId(str)
+		}
+		if isKeyHandle(str, challengeTxt) {
+			newList = append(newList, str)
+		}
+	}
+	return appId, newList
+}
+
+func isKeyHandle(key, challengeTxt string) bool {
+	_, err := base64.StdEncoding.DecodeString(key)
+	if err != nil {
+		return false
+	}
+	return key != challengeTxt
+}
+
+func isAppId(val string) string {
+	obj := map[string]interface{}{}
+	err := json.Unmarshal([]byte(val), &obj)
+	if err != nil {
+		return ""
+	}
+	appId, ok := obj["appid"].(string)
+	if !ok {
+		return ""
+	}
+	return appId
+}
diff --git a/pkg/provider/googleapps/u2f.go b/pkg/provider/googleapps/u2f.go
new file mode 100644
index 0000000..8f9a5f3
--- /dev/null
+++ b/pkg/provider/googleapps/u2f.go
@@ -0,0 +1,150 @@
+package googleapps
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+
+	u2fhost "github.com/marshallbrekka/go-u2fhost"
+)
+
+const (
+	maxOpenRetries = 10
+	retryDelay     = 200 * time.Millisecond
+)
+
+var (
+	errNoDeviceFound = fmt.Errorf("no U2F devices found. device might not be plugged in")
+)
+
+// U2FClient represents a challenge and the device used to respond
+type U2FClient struct {
+	ChallengeNonce string
+	AppID          string
+	Facet          string
+	Device         u2fhost.Device
+	KeyHandle      string
+}
+
+// DeviceFinder is used to mock out finding devices
+type DeviceFinder interface {
+	findDevice() (u2fhost.Device, error)
+}
+
+// U2FDevice is used to support mocking this device with mockery https://github.com/vektra/mockery/issues/210#issuecomment-485026348
+type U2FDevice interface {
+	u2fhost.Device
+}
+
+// NewU2FClient returns a new initialized FIDO1-based U2F client, representing a single device
+func NewU2FClient(challengeNonce, appID, facet, keyHandle string, deviceFinder DeviceFinder) (*U2FClient, error) {
+	var device u2fhost.Device
+	var err error
+
+	retryCount := 0
+	for retryCount < maxOpenRetries {
+		device, err = deviceFinder.findDevice()
+		if err != nil {
+			if err == errNoDeviceFound {
+				return nil, err
+			}
+
+			retryCount++
+			time.Sleep(retryDelay)
+			continue
+		}
+
+		return &U2FClient{
+			Device:         device,
+			ChallengeNonce: challengeNonce,
+			AppID:          appID,
+			KeyHandle:      keyHandle,
+			Facet:          facet,
+		}, nil
+	}
+
+	return nil, fmt.Errorf("failed to create client: %s. exceeded max retries of %d", err, maxOpenRetries)
+}
+
+// ChallengeU2F takes a U2FClient and returns a signed assertion to send to Google
+func (d *U2FClient) ChallengeU2F() (string, error) {
+	if d.Device == nil {
+		return "", errors.New("No Device Found")
+	}
+	request := &u2fhost.AuthenticateRequest{
+		Challenge: b64Safe(d.ChallengeNonce),
+		Facet:     d.Facet,
+		AppId:     d.AppID,
+		KeyHandle: b64Safe(d.KeyHandle),
+	}
+	// do the change
+	prompted := false
+	timeout := time.After(time.Second * 25)
+	interval := time.NewTicker(time.Millisecond * 250)
+
+	d.Device.Open()
+	defer func() {
+		d.Device.Close()
+	}()
+
+	defer interval.Stop()
+	for {
+		select {
+		case <-timeout:
+			return "", errors.New("Failed to get authentication response after 25 seconds")
+		case <-interval.C:
+			response, err := d.Device.Authenticate(request)
+			if err == nil {
+				responseJSON, err := json.Marshal(response)
+				if err != nil {
+					return "", err
+				}
+				fmt.Printf("  ==> Touch accepted. Proceeding with authentication\n")
+				return string(responseJSON), nil
+			}
+
+			switch err.(type) {
+			case *u2fhost.TestOfUserPresenceRequiredError:
+				if !prompted {
+					fmt.Printf("\nTouch the flashing U2F device to authenticate...\n")
+					prompted = true
+				}
+			default:
+				return "", err
+			}
+		}
+	}
+}
+
+// U2FDeviceFinder returns a U2F device
+type U2FDeviceFinder struct{}
+
+func (*U2FDeviceFinder) findDevice() (u2fhost.Device, error) {
+	var err error
+	allDevices := u2fhost.Devices()
+	if len(allDevices) == 0 {
+		return nil, errNoDeviceFound
+	}
+
+	for i, device := range allDevices {
+		err = device.Open()
+		if err != nil {
+			device.Close()
+			continue
+		}
+
+		return allDevices[i], nil
+	}
+
+	return nil, fmt.Errorf("failed to open fido U2F device: %s", err)
+}
+
+func b64Safe(data string) string {
+	val, err := base64.StdEncoding.DecodeString(data)
+	if err != nil {
+		panic(err)
+	}
+	return base64.RawURLEncoding.EncodeToString(val)
+}


Test patch:
diff --git a/pkg/provider/googleapps/u2f_test.go b/pkg/provider/googleapps/u2f_test.go
new file mode 100644
index 0000000..37bf733
--- /dev/null
+++ b/pkg/provider/googleapps/u2f_test.go
@@ -0,0 +1,86 @@
+package googleapps
+
+import (
+	"testing"
+
+	u2fhost "github.com/marshallbrekka/go-u2fhost"
+	"github.com/stretchr/testify/assert"
+	"github.com/versent/saml2aws/mocks"
+)
+
+type fidoClientTests struct {
+	title  string
+	client U2FClient
+	err    error
+}
+
+type MockDeviceFinder struct {
+	device *mocks.U2FDevice
+}
+
+func (m *MockDeviceFinder) findDevice() (u2fhost.Device, error) {
+	return m.device, nil
+}
+
+func TestNewU2FClient(t *testing.T) {
+	challengeNonce := "challengeNonce"
+	appID := "appID"
+	version := "version"
+	keyHandle := "keyHandle"
+	tests := []fidoClientTests{
+		{
+			title: "Returns new client successfully if device found",
+			err:   nil,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.title, func(t *testing.T) {
+			_, err := NewU2FClient(challengeNonce, appID, version, keyHandle, &MockDeviceFinder{&mocks.U2FDevice{}})
+			if test.err != nil {
+				assert.NotNil(t, err)
+				assert.Equal(t, test.err.Error(), err.Error())
+			}
+		})
+	}
+}
+
+func TestChallengeU2F(t *testing.T) {
+	challengeNonce := "dGVzdAo="
+	appID := "appID"
+	facet := "facet"
+	keyHandle := "dGVzdAo="
+	tests := []fidoClientTests{
+		{
+			title: "Returns signed assertion from device",
+			err:   nil,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.title, func(t *testing.T) {
+			device := &mocks.U2FDevice{}
+			mockDeviceFinder := &MockDeviceFinder{device}
+			device.On("Open").Return(nil)
+			request := &u2fhost.AuthenticateRequest{
+				Challenge:          "dGVzdAo",
+				AppId:              appID,
+				Facet:              facet,
+				KeyHandle:          "dGVzdAo",
+				ChannelIdPublicKey: nil,
+				ChannelIdUnused:    false,
+				CheckOnly:          false,
+				WebAuthn:           false,
+			}
+			response := &u2fhost.AuthenticateResponse{}
+			device.On("Authenticate", request).Return(response, nil)
+			device.On("Close").Return(nil)
+			client, _ := NewU2FClient(challengeNonce, appID, facet, keyHandle, mockDeviceFinder)
+			_, err := client.ChallengeU2F()
+			if test.err != nil {
+				assert.NotNil(t, err)
+				assert.Equal(t, test.err.Error(), err.Error())
+			}
+		})
+	}
+}