Repository: Versent/saml2aws
Pull request #81 (2017-11-04 10:32:29)

feat(Keycloak) Added ToTP MFA support to keycloak provider.


Patch:
diff --git a/Makefile b/Makefile
index 20aaf02..1cc35b7 100644
--- a/Makefile
+++ b/Makefile
@@ -68,4 +68,7 @@ clean:
 	rm ./glide
 	rm -fr ./build
 
-.PHONY: default deps compile lint fmt dist release test clean
+generate-mocks:
+	mockery -dir pkg/prompter --all
+
+.PHONY: default deps compile lint fmt dist release test clean generate-mocks
diff --git a/README.md b/README.md
index 548c792..302af55 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ The process goes something like this:
   * ADFS (2.x or 3.x)
   * PingFederate + PingId
   * Okta + (Duo, SMS, TOTP)
-  * KeyCloak
+  * KeyCloak + (TOTP)
 * AWS SAML Provider configured
 
 # Caveats
diff --git a/mocks/Prompter.go b/mocks/Prompter.go
new file mode 100644
index 0000000..720b4ed
--- /dev/null
+++ b/mocks/Prompter.go
@@ -0,0 +1,51 @@
+// Code generated by mockery v1.0.0
+package mocks
+
+import mock "github.com/stretchr/testify/mock"
+
+// Prompter is an autogenerated mock type for the Prompter type
+type Prompter struct {
+	mock.Mock
+}
+
+// Choice provides a mock function with given fields: prompt, options
+func (_m *Prompter) Choice(prompt string, options []string) string {
+	ret := _m.Called(prompt, options)
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func(string, []string) string); ok {
+		r0 = rf(prompt, options)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	return r0
+}
+
+// RequestSecurityCode provides a mock function with given fields: pattern
+func (_m *Prompter) RequestSecurityCode(pattern string) string {
+	ret := _m.Called(pattern)
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func(string) string); ok {
+		r0 = rf(pattern)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	return r0
+}
+
+// StringRequired provides a mock function with given fields: pr
+func (_m *Prompter) StringRequired(pr string) string {
+	ret := _m.Called(pr)
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func(string) string); ok {
+		r0 = rf(pr)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	return r0
+}
diff --git a/pkg/provider/keycloak/example/assertion.html b/pkg/provider/keycloak/example/assertion.html
new file mode 100644
index 0000000..b66f2b3
--- /dev/null
+++ b/pkg/provider/keycloak/example/assertion.html
@@ -0,0 +1,20 @@
+<HTML>
+
+<HEAD>
+    <TITLE>SAML HTTP Post Binding</TITLE>
+    <SCRIPT> if (typeof history.replaceState === 'function') { history.replaceState({}, "some title", "https://id.example.com/auth/realms/master/login-actions/authenticate?client_id=urn%3Aamazon%3Awebservices"); }</SCRIPT>
+</HEAD>
+
+<BODY Onload="document.forms[0].submit()">
+    <FORM METHOD="POST" ACTION="https://signin.aws.amazon.com/saml">
+        <INPUT TYPE="HIDDEN" NAME="SAMLResponse" VALUE="abc123"
+        />
+        <NOSCRIPT>
+            <P>JavaScript is disabled. We strongly recommend to enable it. Click the button below to continue.</P>
+            <INPUT TYPE="SUBMIT"
+                VALUE="CONTINUE" />
+        </NOSCRIPT>
+    </FORM>
+</BODY>
+
+</HTML>
\ No newline at end of file
diff --git a/pkg/provider/keycloak/example/loginpage.html b/pkg/provider/keycloak/example/loginpage.html
new file mode 100644
index 0000000..c27900c
--- /dev/null
+++ b/pkg/provider/keycloak/example/loginpage.html
@@ -0,0 +1,92 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" class="login-pf">
+
+<head>
+    <meta charset="utf-8">
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+    <meta name="robots" content="noindex, nofollow">
+
+            <meta name="viewport" content="width=device-width,initial-scale=1"/>
+    <title>        Log in to Keycloak
+</title>
+    <link rel="icon" href="/auth/resources/3.3.0.final/login/keycloak/img/favicon.ico" />
+            <link href="/auth/resources/3.3.0.final/login/keycloak/lib/patternfly/css/patternfly.css" rel="stylesheet" />
+            <link href="/auth/resources/3.3.0.final/login/keycloak/lib/zocial/zocial.css" rel="stylesheet" />
+            <link href="/auth/resources/3.3.0.final/login/keycloak/css/login.css" rel="stylesheet" />
+</head>
+
+<body class="">
+    <div id="kc-logo"><a href="http://www.keycloak.org"><div id="kc-logo-wrapper"></div></a></div>
+
+    <div id="kc-container" class="">
+        <div id="kc-container-wrapper" class="">
+
+            <div id="kc-header" class="col-xs-12 col-sm-8 col-md-8 col-lg-7">
+                <div id="kc-header-wrapper" class="">        <div class="kc-logo-text"><span>Keycloak</span></div>
+</div>
+            </div>
+
+
+            <div id="kc-content" class="col-sm-12 col-md-12 col-lg-12 container">
+                <div id="kc-content-wrapper" class="row">
+
+
+                    <div id="kc-form" class="col-xs-12 col-sm-8 col-md-8 col-lg-7 login">
+                        <div id="kc-form-wrapper" class="">
+            <form id="kc-form-login" class="form-horizontal" action="https://id.example.com/auth/realms/master/login-actions/authenticate?code=G5PSj-AJ7mC2wRS5yOA5NEGZ7BO97Y0_qUkS5zInmhQ&execution=e0c4f6fe-6f9a-435e-a7ff-d61eb2456d58&client_id=urn%3Aamazon%3Awebservices" method="post">
+                <div class="form-group">
+                    <div class="col-xs-12 col-sm-12 col-md-4 col-lg-3">
+                        <label for="username" class="control-label">Email</label>
+                    </div>
+
+                    <div class="col-xs-12 col-sm-12 col-md-8 col-lg-9">
+                            <input id="username" class="form-control" name="username" value="" type="text" autofocus autocomplete="off" />
+                    </div>
+                </div>
+
+                <div class="form-group">
+                    <div class="col-xs-12 col-sm-12 col-md-4 col-lg-3">
+                        <label for="password" class="control-label">Password</label>
+                    </div>
+
+                    <div class="col-xs-12 col-sm-12 col-md-8 col-lg-9">
+                        <input id="password" class="form-control" name="password" type="password" autocomplete="off" />
+                    </div>
+                </div>
+
+                <div class="form-group">
+                    <div id="kc-form-options" class="col-xs-4 col-sm-5 col-md-offset-4 col-md-4 col-lg-offset-3 col-lg-5">
+                            <div class="checkbox">
+                                <label>
+                                        <input id="rememberMe" name="rememberMe" type="checkbox" tabindex="3"> Remember me
+                                </label>
+                            </div>
+                        <div class="">
+                                <span><a href="/auth/realms/master/login-actions/reset-credentials">Forgot Password?</a></span>
+                        </div>
+                    </div>
+
+                    <div id="kc-form-buttons" class="col-xs-8 col-sm-7 col-md-4 col-lg-4 submit">
+                        <div class="">
+                            <input class="btn btn-primary btn-lg" name="login" id="kc-login" type="submit" value="Log in"/>
+                        </div>
+                     </div>
+                </div>
+            </form>
+                        </div>
+                    </div>
+
+                        <div id="kc-info" class="col-xs-12 col-sm-4 col-md-4 col-lg-5 details">
+                            <div id="kc-info-wrapper" class="">
+            <div id="kc-registration">
+                <span>New user? <a href="/auth/realms/master/login-actions/registration">Register</a></span>
+            </div>
+
+                            </div>
+                        </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/pkg/provider/keycloak/example/mfapage.html b/pkg/provider/keycloak/example/mfapage.html
new file mode 100644
index 0000000..792a67e
--- /dev/null
+++ b/pkg/provider/keycloak/example/mfapage.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"  "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml" class="login-pf">
+
+<head>
+    <meta charset="utf-8">
+    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+    <meta name="robots" content="noindex, nofollow">
+
+            <meta name="viewport" content="width=device-width,initial-scale=1"/>
+    <title>        Log in to Keycloak
+</title>
+    <link rel="icon" href="/auth/resources/3.3.0.final/login/keycloak/img/favicon.ico" />
+            <link href="/auth/resources/3.3.0.final/login/keycloak/lib/patternfly/css/patternfly.css" rel="stylesheet" />
+            <link href="/auth/resources/3.3.0.final/login/keycloak/lib/zocial/zocial.css" rel="stylesheet" />
+            <link href="/auth/resources/3.3.0.final/login/keycloak/css/login.css" rel="stylesheet" />
+<SCRIPT> if (typeof history.replaceState === 'function') {  history.replaceState({}, "some title", "https://wolfebook-2001:8443/auth/realms/master/login-actions/authenticate?execution=a718db5b-6d9e-40a2-a895-4f4505e6f464&client_id=urn%3Aamazon%3Awebservices"); }</SCRIPT></head>
+
+<body class="">
+    <div id="kc-logo"><a href="http://www.keycloak.org"><div id="kc-logo-wrapper"></div></a></div>
+
+    <div id="kc-container" class="">
+        <div id="kc-container-wrapper" class="">
+
+            <div id="kc-header" class="col-xs-12 col-sm-8 col-md-8 col-lg-7">
+                <div id="kc-header-wrapper" class="">        <div class="kc-logo-text"><span>Keycloak</span></div>
+</div>
+            </div>
+
+
+            <div id="kc-content" class="col-sm-12 col-md-12 col-lg-12 container">
+                <div id="kc-content-wrapper" class="row">
+
+
+                    <div id="kc-form" class="col-xs-12 col-sm-8 col-md-8 col-lg-7 login">
+                        <div id="kc-form-wrapper" class="">
+        <form id="kc-totp-login-form" class="form-horizontal" action="https://id.example.com/auth/realms/master/login-actions/authenticate?code=1SYK2D1kwKyOO7JyriHDfP_e9-rNes91_uzfqi8Dc94&execution=a718db5b-6d9e-40a2-a895-4f4505e6f464&client_id=urn%3Aamazon%3Awebservices" method="post">
+            <div class="form-group">
+                <div class="col-xs-12 col-sm-12 col-md-4 col-lg-3">
+                    <label for="totp" class="control-label">One-time code</label>
+                </div>
+
+                <div class="col-xs-12 col-sm-12 col-md-8 col-lg-9">
+                    <input id="totp" name="totp" autocomplete="off" type="text" class="form-control" autofocus />
+                </div>
+            </div>
+
+            <div class="form-group">
+                <div id="kc-form-options" class="col-xs-4 col-sm-5 col-md-offset-4 col-md-4 col-lg-offset-3 col-lg-5">
+                    <div class="">
+                    </div>
+                </div>
+
+                <div id="kc-form-buttons" class="col-xs-8 col-sm-7 col-md-4 col-lg-4 submit">
+                    <div class="">
+                        <input class="btn btn-primary btn-lg" name="login" id="kc-login" type="submit" value="Log in"/>
+                        <input class="btn btn-default btn-lg" name="cancel" id="kc-cancel" type="submit" value="Cancel"/>
+                    </div>
+                </div>
+            </div>
+        </form>
+                        </div>
+                    </div>
+
+                </div>
+            </div>
+        </div>
+    </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/pkg/provider/keycloak/keycloak.go b/pkg/provider/keycloak/keycloak.go
index 4c86652..1bd0283 100644
--- a/pkg/provider/keycloak/keycloak.go
+++ b/pkg/provider/keycloak/keycloak.go
@@ -11,16 +11,22 @@ import (
 
 	"github.com/PuerkitoBio/goquery"
 	"github.com/pkg/errors"
+	"github.com/sirupsen/logrus"
 	"github.com/versent/saml2aws/pkg/cfg"
 	"github.com/versent/saml2aws/pkg/creds"
+	"github.com/versent/saml2aws/pkg/dump"
+	"github.com/versent/saml2aws/pkg/prompter"
 	"github.com/versent/saml2aws/pkg/provider"
 
 	"fmt"
 )
 
+var logger = logrus.WithField("provider", "keycloak")
+
 // Client wrapper around KeyCloak.
 type Client struct {
-	client *provider.HTTPClient
+	client   *provider.HTTPClient
+	prompter prompter.Prompter
 }
 
 // New create a new KeyCloakClient
@@ -37,89 +43,173 @@ func New(idpAccount *cfg.IDPAccount) (*Client, error) {
 	}
 
 	return &Client{
-		client: client,
+		client:   client,
+		prompter: prompter.NewCli(),
 	}, nil
 }
 
 // Authenticate logs into KeyCloak and returns a SAML response
 func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
-	var authSubmitURL string
+
+	authSubmitURL, authForm, err := kc.getLoginForm(loginDetails)
+	if err != nil {
+		return "", errors.Wrap(err, "error retrieving login form from idp")
+	}
+
+	data, err := kc.postLoginForm(authSubmitURL, authForm)
+	if authSubmitURL == "" {
+		return "", fmt.Errorf("error submitting login form")
+	}
+
+	doc, err := goquery.NewDocumentFromReader(bytes.NewBuffer(data))
+	if err != nil {
+		return "", errors.Wrap(err, "error parsing document")
+	}
+
+	if containsTotpForm(doc) {
+		totpSubmitURL, err := extractSubmitURL(doc)
+		if err != nil {
+			return "", errors.Wrap(err, "unable to locate IDP totp form submit URL")
+		}
+
+		doc, err = kc.postTotpForm(totpSubmitURL, doc)
+		if err != nil {
+			return "", errors.Wrap(err, "error posting totp form")
+		}
+	}
+
 	var samlAssertion string
-	authForm := url.Values{}
 
-	keyCloakURL := loginDetails.URL
+	doc.Find("input").Each(func(i int, s *goquery.Selection) {
+		name, ok := s.Attr("name")
+		if !ok {
+			log.Fatalf("unable to locate IDP authentication form submit URL")
+		}
+		if name == "SAMLResponse" {
+			val, ok := s.Attr("value")
+			if !ok {
+				log.Fatalf("unable to locate saml assertion value")
+			}
+			samlAssertion = val
+		}
+	})
+
+	return samlAssertion, nil
+}
 
-	// fmt.Printf("KeyCloak URL: %s\n\n", keyCloakURL)
+func (kc *Client) getLoginForm(loginDetails *creds.LoginDetails) (string, url.Values, error) {
 
-	res, err := kc.client.Get(keyCloakURL)
+	res, err := kc.client.Get(loginDetails.URL)
 	if err != nil {
-		return "", errors.Wrap(err, "error retieving form")
+		return "", nil, errors.Wrap(err, "error retrieving form")
 	}
 
-	// fmt.Printf("Response: %d\n\n", res.StatusCode)
+	logger.WithField("status", res.StatusCode).WithField("url", loginDetails.URL).WithField("res", dump.ResponseString(res)).Debug("GET")
 
 	doc, err := goquery.NewDocumentFromResponse(res)
 	if err != nil {
-		return "", errors.Wrap(err, "failed to build document from response")
+		return "", nil, errors.Wrap(err, "failed to build document from response")
 	}
 
+	authForm := url.Values{}
+
 	doc.Find("input").Each(func(i int, s *goquery.Selection) {
 		updateKeyCloakFormData(authForm, s, loginDetails)
 	})
 
-	doc.Find("form").Each(func(i int, s *goquery.Selection) {
-		action, ok := s.Attr("action")
-		if !ok {
-			return
-		}
-		authSubmitURL = action
-	})
-
-	if authSubmitURL == "" {
-		return samlAssertion, fmt.Errorf("unable to locate IDP authentication form submit URL")
+	authSubmitURL, err := extractSubmitURL(doc)
+	if err != nil {
+		return "", nil, errors.Wrap(err, "unable to locate IDP authentication form submit URL")
 	}
 
-	// log.Printf("id authentication url: %s", authSubmitURL)
+	return authSubmitURL, authForm, nil
+}
+
+func (kc *Client) postLoginForm(authSubmitURL string, authForm url.Values) ([]byte, error) {
 
 	req, err := http.NewRequest("POST", authSubmitURL, strings.NewReader(authForm.Encode()))
 	if err != nil {
-		return samlAssertion, errors.Wrap(err, "error building authentication request")
+		return nil, errors.Wrap(err, "error building authentication request")
 	}
 
 	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
 
-	res, err = kc.client.Do(req)
+	res, err := kc.client.Do(req)
 	if err != nil {
-		return samlAssertion, errors.Wrap(err, "error retieving login form")
+		return nil, errors.Wrap(err, "error retrieving login form")
 	}
 
-	// log.Printf("res code = %v status = %s", res.StatusCode, res.Status)
+	logger.WithField("status", res.StatusCode).WithField("url", authSubmitURL).WithField("res", dump.ResponseString(res)).Debug("POST")
 
 	data, err := ioutil.ReadAll(res.Body)
 	if err != nil {
-		return samlAssertion, errors.Wrap(err, "error retieving body")
+		return nil, errors.Wrap(err, "error retrieving body")
+	}
+
+	return data, nil
+}
+
+func (kc *Client) postTotpForm(totpSubmitURL string, doc *goquery.Document) (*goquery.Document, error) {
+
+	otpForm := url.Values{}
+
+	var token = kc.prompter.RequestSecurityCode("000000")
+
+	doc.Find("input").Each(func(i int, s *goquery.Selection) {
+		updateOTPFormData(otpForm, s, token)
+	})
+
+	req, err := http.NewRequest("POST", totpSubmitURL, strings.NewReader(otpForm.Encode()))
+	if err != nil {
+		return nil, errors.Wrap(err, "error building MFA request")
 	}
 
-	doc, err = goquery.NewDocumentFromReader(bytes.NewBuffer(data))
+	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+	logger.WithField("url", totpSubmitURL).WithField("req", dump.RequestString(req)).Debug("POST")
+
+	res, err := kc.client.Do(req)
 	if err != nil {
-		return samlAssertion, errors.Wrap(err, "error parsing document")
+		return nil, errors.Wrap(err, "error retrieving content")
 	}
 
-	doc.Find("input").Each(func(i int, s *goquery.Selection) {
-		name, ok := s.Attr("name")
+	logger.WithField("status", res.StatusCode).WithField("url", totpSubmitURL).WithField("res", dump.ResponseString(res)).Debug("POST")
+
+	doc, err = goquery.NewDocumentFromResponse(res)
+	if err != nil {
+		return nil, errors.Wrap(err, "error reading totp form response")
+	}
+
+	return doc, nil
+}
+
+func extractSubmitURL(doc *goquery.Document) (string, error) {
+
+	var submitURL string
+
+	doc.Find("form").Each(func(i int, s *goquery.Selection) {
+		action, ok := s.Attr("action")
 		if !ok {
-			log.Fatalf("unable to locate IDP authentication form submit URL")
-		}
-		if name == "SAMLResponse" {
-			val, ok := s.Attr("value")
-			if !ok {
-				log.Fatalf("unable to locate saml assertion value")
-			}
-			samlAssertion = val
+			return
 		}
+		submitURL = action
 	})
 
-	return samlAssertion, nil
+	if submitURL == "" {
+		return "", fmt.Errorf("unable to locate form submit URL")
+	}
+
+	return submitURL, nil
+}
+
+func containsTotpForm(doc *goquery.Document) bool {
+	totpIndex := doc.Find("input#totp").Index()
+
+	if totpIndex != -1 {
+		return true
+	}
+
+	return false
 }
 
 func updateKeyCloakFormData(authForm url.Values, s *goquery.Selection, user *creds.LoginDetails) {
@@ -142,3 +232,16 @@ func updateKeyCloakFormData(authForm url.Values, s *goquery.Selection, user *cre
 		authForm.Add(name, val)
 	}
 }
+
+func updateOTPFormData(otpForm url.Values, s *goquery.Selection, token string) {
+	name, ok := s.Attr("name")
+	//	log.Printf("name = %s ok = %v", name, ok)
+	if !ok {
+		return
+	}
+	lname := strings.ToLower(name)
+	if strings.Contains(lname, "totp") {
+		otpForm.Add(name, token)
+	}
+
+}
diff --git a/saml2aws.go b/saml2aws.go
index de4fbb1..1631df9 100644
--- a/saml2aws.go
+++ b/saml2aws.go
@@ -23,8 +23,8 @@ var MFAsByProvider = ProviderList{
 	"ADFS2":     []string{"None"},
 	"Ping":      []string{"PingID"},
 	"JumpCloud": []string{"None"},
-	"Okta":      []string{"Duo"},
-	"KeyCloak":  []string{"None"},
+	"Okta":      []string{"Auto"}, // automatically detects DUO, SMS and ToTP
+	"KeyCloak":  []string{"Auto"}, // automatically detects ToTP
 }
 
 // Names get a list of provider names


Test patch:
diff --git a/pkg/provider/keycloak/keycloak_test.go b/pkg/provider/keycloak/keycloak_test.go
new file mode 100644
index 0000000..a0d8750
--- /dev/null
+++ b/pkg/provider/keycloak/keycloak_test.go
@@ -0,0 +1,98 @@
+package keycloak
+
+import (
+	"bytes"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/PuerkitoBio/goquery"
+
+	"github.com/stretchr/testify/require"
+	"github.com/versent/saml2aws/mocks"
+	"github.com/versent/saml2aws/pkg/creds"
+	"github.com/versent/saml2aws/pkg/provider"
+)
+
+const (
+	exampleLoginURL = "https://id.example.com/auth/realms/master/login-actions/authenticate?code=G5PSj-AJ7mC2wRS5yOA5NEGZ7BO97Y0_qUkS5zInmhQ&execution=e0c4f6fe-6f9a-435e-a7ff-d61eb2456d58&client_id=urn%3Aamazon%3Awebservices"
+)
+
+func TestClient_getLoginForm(t *testing.T) {
+
+	data, err := ioutil.ReadFile("example/loginpage.html")
+	require.Nil(t, err)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	kc := Client{client: &provider.HTTPClient{Client: http.Client{}}}
+	loginDetails := &creds.LoginDetails{URL: ts.URL, Username: "test", Password: "test123"}
+
+	submitURL, authForm, err := kc.getLoginForm(loginDetails)
+	require.Nil(t, err)
+	require.Equal(t, exampleLoginURL, submitURL)
+	require.Equal(t, url.Values{
+		"username": []string{"test"},
+		"password": []string{"test123"},
+		"login":    []string{"Log in"},
+	}, authForm)
+}
+
+func TestClient_postLoginForm(t *testing.T) {
+
+	data, err := ioutil.ReadFile("example/mfapage.html")
+	require.Nil(t, err)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	loginForm := url.Values{
+		"username": []string{"test"},
+		"password": []string{"test123"},
+		"login":    []string{"Log in"},
+	}
+
+	kc := Client{client: &provider.HTTPClient{Client: http.Client{}}}
+
+	content, err := kc.postLoginForm(ts.URL, loginForm)
+	require.Nil(t, err)
+	require.NotNil(t, content)
+}
+
+func TestClient_postTotpForm(t *testing.T) {
+
+	data, err := ioutil.ReadFile("example/assertion.html")
+	require.Nil(t, err)
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write(data)
+	}))
+	defer ts.Close()
+
+	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
+	require.Nil(t, err)
+	pr := &mocks.Prompter{}
+
+	pr.Mock.On("RequestSecurityCode", "000000").Return("111222")
+
+	kc := Client{client: &provider.HTTPClient{Client: http.Client{}}, prompter: pr}
+
+	kc.postTotpForm(ts.URL, doc)
+}
+
+func TestClient_containsTotpForm(t *testing.T) {
+	data, err := ioutil.ReadFile("example/mfapage.html")
+	require.Nil(t, err)
+
+	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
+	require.Nil(t, err)
+
+	require.True(t, containsTotpForm(doc))
+}