Repository: Versent/saml2aws
Pull request #183 (2018-06-18 09:43:59)

feat(Google Apps) Added support for mobile prompt 2fa.
* Also added some docs / notes on google apps setup.


Patch:
diff --git a/README.md b/README.md
index 7d1cab0..ae31d0e 100644
--- a/README.md
+++ b/README.md
@@ -16,12 +16,12 @@ The process goes something like this:
 
 # Requirements
 
-* Identity Provider
+* One of the supported Identity Providers
   * ADFS (2.x or 3.x)
   * PingFederate + PingId
-  * Okta + (Duo, SMS, TOTP)
+  * [Okta](pkg/provider/okta/README.md)
   * KeyCloak + (TOTP)
-  * Google Apps SAML see [How to Set Up Federated Single Sign-On to AWS Using Google Apps](https://aws.amazon.com/blogs/security/how-to-set-up-federated-single-sign-on-to-aws-using-google-apps/)
+  * [Google Apps](pkg/provider/googleapps/README.md)
 * AWS SAML Provider configured
 
 # Caveats
@@ -274,12 +274,6 @@ The second emits the content of requests and responses, this includes authentica
 DUMP_CONTENT=true saml2aws login --verbose
 ```
 
-# IDP-specific notes
-
-Additional IDP-specific notes are available for:
-
-* [Okta](pkg/provider/okta/README.md)
-
 # License
 
 This code is Copyright (c) 2018 [Versent](http://versent.com.au) and released under the MIT license. All rights not explicitly granted in the MIT license are reserved. See the included LICENSE.md file for more details.
diff --git a/pkg/provider/googleapps/README.md b/pkg/provider/googleapps/README.md
new file mode 100644
index 0000000..ab2646c
--- /dev/null
+++ b/pkg/provider/googleapps/README.md
@@ -0,0 +1,36 @@
+# googleapps
+
+This provider uses SAML with Google Apps to enable authentication of users to AWS. 
+
+# prerequisites
+
+Setup your Google Apps and AWS Account as per one of the configuration guides.
+
+* [How to Set Up Federated Single Sign-On to AWS Using Google Apps](https://aws.amazon.com/blogs/security/how-to-set-up-federated-single-sign-on-to-aws-using-google-apps/)
+* [Using Google Apps SAML SSO to do one-click login to AWS](https://blog.faisalmisle.com/2015/11/using-google-apps-saml-sso-to-do-one-click-login-to-aws/)
+
+# configuration
+
+The key attribute in configuring this provider is the URL which can be copied from the google apps, application list (I just pulled it from the HTML). An example of this is as follows:
+
+`https://accounts.google.com/o/saml2/initsso?idpid=XXXXXXX&spid=YYYYY&forceauthn=false`
+
+Where the following attributes are replace with:
+
+* `XXXXX` is IdP identifier for your Google Apps Account.
+* `YYYYY` is SP identifier for the AWS SAML application, in your Google Apps Account.
+
+# 2-factor support
+
+Currently this provider supports:
+
+* ToTP using applications like Google Authenticator or Authy
+* SMS
+* Google Prompt (Mobile Application)
+
+# prior work
+
+In addition to my own effort deconstructing this, I also used the following as resources:
+
+* https://github.com/wheniwork/keyme
+* https://github.com/cevoaustralia/aws-google-auth
diff --git a/pkg/provider/googleapps/example/challenge-prompt.html b/pkg/provider/googleapps/example/challenge-prompt.html
new file mode 100644
index 0000000..419acfa
--- /dev/null
+++ b/pkg/provider/googleapps/example/challenge-prompt.html
@@ -0,0 +1,126 @@
+<!doctype html>
+<html lang="en" dir="ltr">
+
+<head>
+    <base href="https://accounts.google.com/">
+    <title>Google Accounts</title>
+</head>
+
+<body id="yDmH0d">
+    <div class="s2h6df">
+        <div class="JYXKFb IA6off">
+            <div class="ql1pVb ZnXjYc EaNIqc">
+                <div class="omTHz" aria-label="Google"></div>
+            </div>
+        </div>
+        <div class="RgEUV ZnXjYc EaNIqc JhUD8d">
+            <div>
+                <div class="glT6eb">
+                    <div jsname="IDL96d">
+                        <h1>2-Step Verification</h1>
+                    </div>
+                    <div jsname="jqgtP">
+                        <h2>This extra step shows it’s really you trying to sign in</h2>
+                    </div>
+                </div>
+            </div>
+            <div jsmodel="qvHqZ" data-devices="%.@.[&quot;Google Pixel&quot;]
+,[]
+,[]
+,null,true,true]
+">
+                <div class="uUNkEc qnfQjf" id="signInRestart" jsname="tALZ7c" jscontroller="adrjLb">
+                    <form action="/signin/challenge" method="POST" data-challengeentry="5">
+                        <input name="subAction" type="hidden" value="startChallenge">
+                        <input name="challengeId" type="hidden" value="5">
+                        <input name="challengeType" type="hidden" value="4">
+                        <input name="continue" type="hidden" value="https://accounts.google.com/o/saml2/initsso?idpid=XXXXXX&amp;spid=YYYYYY&amp;forceauthn=false&amp;from_login=1&amp;as=123123-w">
+                        <input name="scc" type="hidden" value="1">
+                        <input name="sarp" type="hidden" value="1">
+                        <input name="checkedDomains" type="hidden" value="youtube">
+                        <input name="pstMsg" type="hidden" value="0">
+                        <input name="TL" type="hidden" value="XXXXXX">
+                        <input type="hidden" name="gxf" id="gxf" value="XXXXXX:1529303586210">Didn't get the prompt?
+                        <button type="submit" id="HmIokf" class="g1C42c" jsaction="click:JngxIb" jsname="klqQrb">Resend it</button>
+                    </form>
+                </div>
+                <div class="ZNgtu">
+                    <div class="LJtPoc" jsname="Ki8mld" jscontroller="Q6ivzf" jsaction="rcuQ6b:XXXXXX,XXXXXX" data-context="TWO_STEP_VERIFICATION"
+                        data-gapi-url="https://apis.google.com/js/base.js" data-api-key="XXXXXX"
+                        data-tx-id="XXXXXX-XXXXXX-XXXXXX="
+                        data-tx-lifetime="180000">
+                        <form method="POST" id="challenge" action="/signin/challenge/az/5" jsname="rzWj5" jscontroller="HNBfvc"
+                            jsaction="submit:zbvklb" jsshadow>
+                            <content>
+                                <input name="challengeId" type="hidden" id="challengeId" value="5">
+                                <input name="challengeType" type="hidden" id="challengeType" value="4">
+                                <input name="continue" type="hidden" value="https://accounts.google.com/o/saml2/initsso?idpid=XXXXXX&amp;spid=254876876282&amp;forceauthn=false&amp;from_login=1&amp;as=emST7MqM0J28piqRLOG9-w">
+                                <input name="scc" type="hidden" value="1">
+                                <input name="sarp" type="hidden" value="1">
+                                <input name="checkedDomains" type="hidden" value="youtube">
+                                <input name="pstMsg" type="hidden" value="0">
+                                <input name="TL" type="hidden" value="XXXXXX-XXXXXX-XXXXXX-XXXXXX-xXw">
+                                <input type="hidden" name="gxf" id="gxf" value="XXXXXX:1529303586210">
+                                <div jsname="KrwUDc">
+                                    <div jsname="wsPcPe">
+                                        <img jsname="TqVmm" class="JC07Dd" src="//ssl.gstatic.com/accounts/marc/authzen_signin.gif"
+                                            alt="">
+                                        <div class="EGmPD" jsname="BCqkPb">Unlock your Google Pixel 2</div>
+                                        <div class="VnJmLc" jsname="NhJ5Dd">
+                                            <div class="H16gm" id="azApprovalMsg">Google sent a notification to your Google Pixel 2. Tap
+                                                <strong>Yes</strong> on the notification to continue.
+                                                <div jsname="XmIfSd" class="x8yCrf qnfQjf">
+                                                    <img class="mBKf9e" src="//ssl.gstatic.com/ui/v1/activityindicator/loading.svg">
+                                                </div>
+                                            </div>
+                                            <div class="yqiuZb"></div>
+                                        </div>
+                                        <input id="authzenToken" type="hidden" name="token" jsname="iFpmR">
+                                        <input id="azAction" type="hidden" name="action" value="VERIFY">
+                                        <noscript>You appear to have Javascript disabled.After you've finished on your phone, press
+                                            the button below.
+                                            <button type="submit" class="MK9CEd MVpUfe" jsname="M2UYVd"
+                                                jscontroller="rrJN5c" jsaction="aJAbCd:zbvklb" formnovalidate value="submit"
+                                                id="nojssubmit">I have responded from my phone</button>
+                                        </noscript>
+                                        <div class="ARshqb">
+                                            <input type="checkbox" name="TrustDevice" id="trustDevice" class="aCOJmf" checked>
+                                            <span>Don&#39;t ask again on this computer</span>
+                                            <div class="Bfmfyc" role="tooltip">
+                                                <div class="x7qQqf"></div>
+                                                <div class="hzC8Lb">For your convenience, keep this checked. On shared devices, additional precautions
+                                                    are recommended.
+                                                    <a href="https://support.google.com/accounts/?p=securesignin&amp;hl=en"
+                                                        target="_blank">Learn more</a>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </content>
+                        </form>
+                    </div>
+                </div>
+                <div class=" KSYbxc ">
+                    <form method="POST" action="/signin/challenge/skip">
+                        <input name="challengeId" type="hidden" value="5">
+                        <input name="continue" type="hidden" value="https://accounts.google.com/o/saml2/initsso?idpid=C027cb9bv&amp;spid=254876876282&amp;forceauthn=false&amp;from_login=1&amp;as=emST7MqM0J28piqRLOG9-w">
+                        <input name="scc" type="hidden" value="1">
+                        <input name="sarp" type="hidden" value="1">
+                        <input name="checkedDomains" type="hidden" value="youtube">
+                        <input name="pstMsg" type="hidden" value="0">
+                        <input name="TL" type="hidden" value="XXXXXX-XXXXXX-XXXXXX-XXXXXX-XXXXXX">
+                        <input type="hidden" name="gxf" id="gxf" value="XXXXXX:1529303586210">
+                        <input id="skipChallenge" type="submit" jsname="rwR6T" class="g1C42c" value="Try another way to sign in">
+                    </form>
+                </div>
+                <div class="M0leCe">
+                    <span jsname="tODuDc">mark@wolfe.id.au</span>
+                    <a href="https://accounts.google.com/AccountChooser?continue=https%3A%2F%2Faccounts.google.com%2Fo%2Fsaml2%2Finitsso%3Fidpid%3DC027cb9bv%26spid%3D254876876282%26forceauthn%3Dfalse%26from_login%3D1%26as%3DemST7MqM0J28piqRLOG9-w&amp;sarp=1"
+                        class="vHOx3b">Use a different account</a>
+                </div>
+            </div>
+        </div>
+</body>
+
+</html>
\ No newline at end of file
diff --git a/pkg/provider/googleapps/googleapps.go b/pkg/provider/googleapps/googleapps.go
index f3fbce7..7965ee5 100644
--- a/pkg/provider/googleapps/googleapps.go
+++ b/pkg/provider/googleapps/googleapps.go
@@ -1,6 +1,8 @@
 package googleapps
 
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
 	"log"
 	"net/http"
@@ -197,12 +199,37 @@ func (kc *Client) loadChallengePage(submitURL string, referer string, authForm u
 			var token = prompter.RequestSecurityCode("000000")
 
 			responseForm.Set("Pin", token)
+			responseForm.Set("TrustDevice", "on") // Don't ask again on this computer
 
 			return kc.loadResponsePage(u.String(), submitURL, responseForm)
 		case strings.Contains(secondActionURL, "challenge/ipp/"): // handle SMS challenge
+
 			var token = prompter.StringRequired("Enter SMS token: G-")
 
 			responseForm.Set("Pin", token)
+			responseForm.Set("TrustDevice", "on") // Don't ask again on this computer
+
+			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"})
+
+			logrus.Debugf("prompt with data values: %+v", dataAttrs)
+
+			waitValues := map[string]string{
+				"txId": dataAttrs["data-tx-id"],
+			}
+
+			fmt.Println("Open the Google App, and tap 'Yes' on the prompt to sign in")
+
+			_, err := kc.postJSON(fmt.Sprintf("https://content.googleapis.com/cryptauth/v1/authzen/awaittx?alt=json&key=%s", dataAttrs["data-api-key"]), waitValues, submitURL)
+			if err != nil {
+				return nil, errors.Wrap(err, "unable to extract post wait tx form")
+			}
+
+			// responseForm.Set("Pin", token)
+			responseForm.Set("TrustDevice", "on") // Don't ask again on this computer
 
 			return kc.loadResponsePage(u.String(), submitURL, responseForm)
 		}
@@ -214,6 +241,26 @@ func (kc *Client) loadChallengePage(submitURL string, referer string, authForm u
 
 }
 
+func (kc *Client) postJSON(submitURL string, values map[string]string, referer string) (*http.Response, error) {
+
+	data, _ := json.Marshal(values)
+
+	req, err := http.NewRequest("POST", submitURL, bytes.NewReader(data))
+	if err != nil {
+		return nil, errors.Wrap(err, "error retrieving login form")
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Referer", referer)
+
+	res, err := kc.client.Do(req)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to post JSON")
+	}
+
+	return res, nil
+}
+
 func (kc *Client) loadResponsePage(submitURL string, referer string, responseForm url.Values) (*goquery.Document, error) {
 
 	req, err := http.NewRequest("POST", submitURL, strings.NewReader(responseForm.Encode()))
@@ -309,3 +356,18 @@ func extractNodeText(doc *goquery.Document, tag, txt string) string {
 
 	return res
 }
+
+func extractDataAttributes(doc *goquery.Document, query string, attrsToSelect []string) map[string]string {
+
+	dataAttrs := make(map[string]string)
+
+	doc.Find(query).Each(func(_ int, sel *goquery.Selection) {
+		for _, f := range attrsToSelect {
+			if val, ok := sel.Attr(f); ok {
+				dataAttrs[f] = val
+			}
+		}
+	})
+
+	return dataAttrs
+}


Test patch:
diff --git a/pkg/provider/googleapps/googleapps_test.go b/pkg/provider/googleapps/googleapps_test.go
index 25bb1f7..b226ede 100644
--- a/pkg/provider/googleapps/googleapps_test.go
+++ b/pkg/provider/googleapps/googleapps_test.go
@@ -1,6 +1,7 @@
 package googleapps
 
 import (
+	"bytes"
 	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
@@ -72,3 +73,14 @@ func TestChallengePage(t *testing.T) {
 	require.Nil(t, err)
 	require.NotNil(t, challengeDoc)
 }
+
+func TestExtractDataAttributes(t *testing.T) {
+	data, err := ioutil.ReadFile("example/challenge-prompt.html")
+	require.Nil(t, err)
+	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
+	require.Nil(t, err)
+
+	dataAttrs := extractDataAttributes(doc, "div[data-context]", []string{"data-context", "data-gapi-url", "data-tx-id", "data-tx-lifetime"})
+
+	require.Equal(t, "https://apis.google.com/js/base.js", dataAttrs["data-gapi-url"])
+}