Repository: Versent/saml2aws

Issue:
Keycloak authenticate via other brokers
We have a specific setup for our organization where we use multiple keycloak brokers to authenticate different regions of the organization. We are able to access these keycloak brokers when logging in through the browser by selecting the "login with ..." button below the login screen.

This poses a problem with our saml2aws implementation where anyone trying to authenticate against an AWS account using keycloak will have to use the browser mode in order to login. The browser mode supported by saml2aws is not the greatest, and is quite a clunky experience. It will not remember passwords, sessions, or allow extensions, causing quite a long logistical hurdle for logging in to an account in a different federation. 




Discussion:

Solution patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index ebf3666..d287663 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -12,7 +12,7 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       matrix:
-        os: [ubuntu-latest, macOS-latest, macos-12]
+        os: [ubuntu-latest, macOS-latest, macos-13]
     steps:
       - name: Check out code into the Go module directory
         uses: actions/checkout@v4
@@ -76,7 +76,7 @@ jobs:
 
       - uses: actions/download-artifact@v4
         with:
-          name: macos-12_reports
+          name: macos-13_reports
           path: reports
 
       - name: Codecov
diff --git a/cmd/saml2aws/commands/login.go b/cmd/saml2aws/commands/login.go
index 6884f4b..77ec1f6 100644
--- a/cmd/saml2aws/commands/login.go
+++ b/cmd/saml2aws/commands/login.go
@@ -25,7 +25,6 @@ import (
 
 // Login login to ADFS
 func Login(loginFlags *flags.LoginExecFlags) error {
-
 	logger := logrus.WithField("command", "login")
 
 	account, err := buildIdpAccount(loginFlags)
@@ -258,6 +257,11 @@ func resolveLoginDetails(account *cfg.IDPAccount, loginFlags *flags.LoginExecFla
 		loginDetails.DownloadBrowser = account.DownloadBrowser
 	}
 
+	// parse KCBroker if set
+	if account.KCBroker != "" {
+		loginDetails.KCBroker = account.KCBroker
+	}
+
 	// log.Printf("loginDetails %+v", loginDetails)
 
 	// if skip prompt was passed just pass back the flag values
diff --git a/cmd/saml2aws/main.go b/cmd/saml2aws/main.go
index fe6a963..e94f4a6 100644
--- a/cmd/saml2aws/main.go
+++ b/cmd/saml2aws/main.go
@@ -91,6 +91,7 @@ func main() {
 	app.Flag("disable-keychain", "Do not use keychain at all. This will also disable Okta sessions & remembering MFA device. (env: SAML2AWS_DISABLE_KEYCHAIN)").Envar("SAML2AWS_DISABLE_KEYCHAIN").BoolVar(&commonFlags.DisableKeychain)
 	app.Flag("region", "AWS region to use for API requests, e.g. us-east-1, us-gov-west-1, cn-north-1 (env: SAML2AWS_REGION)").Envar("SAML2AWS_REGION").Short('r').StringVar(&commonFlags.Region)
 	app.Flag("prompter", "The prompter to use for user input (default, pinentry)").StringVar(&commonFlags.Prompter)
+	app.Flag("kc-broker", "The kc broker to use when authenticating via keycloak").StringVar(&commonFlags.KCBroker)
 
 	// `configure` command and settings
 	cmdConfigure := app.Command("configure", "Configure a new IDP account.")
@@ -190,7 +191,6 @@ func main() {
 	http.DefaultTransport.(*http.Transport).Proxy = http.ProxyFromEnvironment
 
 	logrus.WithField("command", command).Debug("Running")
-
 	var err error
 	switch command {
 	case cmdScript.FullCommand():
diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go
index 101491c..c2b9277 100644
--- a/pkg/cfg/cfg.go
+++ b/pkg/cfg/cfg.go
@@ -69,6 +69,7 @@ type IDPAccount struct {
 	Prompter              string `ini:"prompter"`
 	KCAuthErrorMessage    string `ini:"kc_auth_error_message,omitempty"` // used by KeyCloak; hide from user if not set
 	KCAuthErrorElement    string `ini:"kc_auth_error_element,omitempty"` // used by KeyCloak; hide from user if not set
+	KCBroker              string `ini:"kc_broker"`                       // used by KeyCloak;
 }
 
 func (ia IDPAccount) String() string {
diff --git a/pkg/creds/creds.go b/pkg/creds/creds.go
index 5aa6971..9ab7d1d 100644
--- a/pkg/creds/creds.go
+++ b/pkg/creds/creds.go
@@ -13,4 +13,5 @@ type LoginDetails struct {
 	URL               string
 	StateToken        string // used by Okta
 	OktaSessionCookie string // used by Okta
+	KCBroker          string // used by KeyCloak
 }
diff --git a/pkg/flags/flags.go b/pkg/flags/flags.go
index 167980f..0a8ecad 100644
--- a/pkg/flags/flags.go
+++ b/pkg/flags/flags.go
@@ -39,6 +39,7 @@ type CommonFlags struct {
 	DisableRememberDevice bool
 	DisableSessions       bool
 	Prompter              string
+	KCBroker              string
 }
 
 // LoginExecFlags flags for the Login / Exec commands
@@ -147,7 +148,9 @@ func ApplyFlagOverrides(commonFlags *CommonFlags, account *cfg.IDPAccount) {
 	if commonFlags.Prompter != "" {
 		account.Prompter = commonFlags.Prompter
 	}
-
+	if commonFlags.KCBroker != "" {
+		account.KCBroker = commonFlags.KCBroker
+	}
 	// select the prompter
 	if commonFlags.Prompter != "" {
 		account.Prompter = commonFlags.Prompter
diff --git a/pkg/provider/keycloak/example/authError-invalidPassword-v2.html b/pkg/provider/keycloak/example/authError-invalidPassword-v2.html
new file mode 100644
index 0000000..cd39364
--- /dev/null
+++ b/pkg/provider/keycloak/example/authError-invalidPassword-v2.html
@@ -0,0 +1,175 @@
+<!DOCTYPE html>
+<html class="login-pf" lang="en">
+
+<head>
+  <meta charset="utf-8">
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+  <meta name="robots" content="noindex, nofollow">
+  <meta name="color-scheme" content="light dark">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+
+  <title>Sign in to internal</title>
+  <link rel="icon" href="/resources/kb6gy/login/keycloak.v2/img/favicon.ico" />
+  <link href="/resources/kb6gy/common/keycloak/vendor/patternfly-v5/patternfly.min.css" rel="stylesheet" />
+  <link href="/resources/kb6gy/common/keycloak/vendor/patternfly-v5/patternfly-addons.css" rel="stylesheet" />
+  <link href="/resources/kb6gy/login/keycloak.v2/css/styles.css" rel="stylesheet" />
+  <script type="importmap">
+    {
+        "imports": {
+            "rfc4648": "/resources/kb6gy/common/keycloak/vendor/rfc4648/rfc4648.js"
+        }
+    }
+  </script>
+  <script type="module" async blocking="render">
+    const DARK_MODE_CLASS = "pf-v5-theme-dark";
+    const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+
+    updateDarkMode(mediaQuery.matches);
+    mediaQuery.addEventListener("change", (event) => updateDarkMode(event.matches));
+
+    function updateDarkMode(isEnabled) {
+      const { classList } = document.documentElement;
+
+      if (isEnabled) {
+        classList.add(DARK_MODE_CLASS);
+      } else {
+        classList.remove(DARK_MODE_CLASS);
+      }
+    }
+  </script>
+  <script type="module" src="/resources/kb6gy/login/keycloak.v2/js/passwordVisibility.js"></script>
+  <script type="module">
+    import { startSessionPolling } from "/resources/kb6gy/login/keycloak.v2/js/authChecker.js";
+
+    startSessionPolling(
+      "/realms/internal/login-actions/restart?client_id=urn%3Aamazon%3Awebservices&tab_id=tabId&client_data=clientData&skip_logout=true"
+    );
+  </script>
+  <script type="module">
+    import { checkAuthSession } from "/resources/kb6gy/login/keycloak.v2/js/authChecker.js";
+
+    checkAuthSession(
+      "sessionId"
+    );
+  </script>
+  <script>
+    // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
+    const isFirefox = true;
+  </script>
+  <SCRIPT> if (typeof history.replaceState === 'function') {  history.replaceState({}, "some title", "https://my.domain/realms/internal/login-actions/authenticate?execution=uuid&client_id=urn%3Aamazon%3Awebservices&tab_id=tabId&client_data=clientData"); }</SCRIPT></head>
+
+<body id="keycloak-bg" class="">
+<div class="pf-v5-c-login">
+  <div class="pf-v5-c-login__container">
+    <header id="kc-header" class="pf-v5-c-login__header">
+      <div id="kc-header-wrapper"
+           class="pf-v5-c-brand">internal</div>
+    </header>
+    <main class="pf-v5-c-login__main">
+      <div class="pf-v5-c-login__main-header">
+        <h1 class="pf-v5-c-title pf-m-3xl" id="kc-page-title"><!-- template: login.ftl -->
+
+          Sign in to your account
+
+        </h1>
+      </div>
+      <div class="pf-v5-c-login__main-body">
+
+
+        <!-- template: login.ftl -->
+
+        <div id="kc-form">
+          <div id="kc-form-wrapper">
+            <form id="kc-form-login" class="pf-v5-c-form" onsubmit="login.disabled = true; return true;" action="https://my.domain/realms/internal/login-actions/authenticate?session_code=sessionId&amp;execution=uuid&amp;client_id=urn%3Aamazon%3Awebservices&amp;tab_id=tabId&amp;client_data=clientData" method="post" novalidate="novalidate">
+
+              <div class="pf-v5-c-form__group">
+                <div class="pf-v5-c-form__label">
+                  <label for="username" class="pf-v5-c-form__label">
+        <span class="pf-v5-c-form__label-text">
+                                        Username or email
+
+        </span>
+                  </label>
+                </div>
+
+                <span class="pf-v5-c-form-control pf-m-error">
+        <input id="username" name="username" value="aorlovskiy" type="text" autocomplete="username" autofocus
+               aria-invalid="true"/>
+    <span class="pf-v5-c-form-control__utilities">
+        <span class="pf-v5-c-form-control__icon pf-m-status">
+          <i class="fas fa-exclamation-circle" aria-hidden="true"></i>
+        </span>
+    </span>
+    </span>
+
+                <div id="input-error-container-username">
+                  <div class="pf-v5-c-form__helper-text" aria-live="polite">
+                    <div class="pf-v5-c-helper-text">
+                      <div class="pf-v5-c-helper-text__item pf-m-error" id="input-error-username">
+                        <span class="pf-v5-c-helper-text__item-text pf-m-error kc-feedback-text">
+                            Invalid username or password.
+                        </span>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </div>
+
+
+              <div class="pf-v5-c-form__group">
+                <div class="pf-v5-c-form__label">
+                  <label for="password" class="pf-v5-c-form__label">
+        <span class="pf-v5-c-form__label-text">
+            Password
+        </span>
+                  </label>
+                </div>
+
+                <div class="pf-v5-c-input-group">
+                  <div class="pf-v5-c-input-group__item pf-m-fill">
+        <span class="pf-v5-c-form-control ">
+          <input id="password" name="password" value="" type="password" autocomplete="current-password"
+                 aria-invalid=""/>
+        </span>
+                  </div>
+                  <div class="pf-v5-c-input-group__item">
+                    <button class="pf-v5-c-button pf-m-control" type="button" aria-label="Show password"
+                            aria-controls="password" data-password-toggle
+                            data-icon-show="fa-eye fas" data-icon-hide="fa-eye-slash fas"
+                            data-label-show="Show password" data-label-hide="Hide password">
+                      <i class="fa-eye fas" aria-hidden="true"></i>
+                    </button>
+                  </div>
+                </div>
+
+                <div id="input-error-container-password">
+                </div>
+              </div>
+
+
+              <div class="pf-v5-c-form__group">
+              </div>
+
+              <input type="hidden" id="id-hidden-input" name="credentialId" />
+              <div class="pf-v5-c-form__group">
+                <div class="pf-v5-c-form__actions">
+                  <button class="pf-v5-c-button pf-m-primary pf-m-block " name="login" id="kc-login" type="submit">Sign In</button>
+                </div>
+              </div>
+            </form>
+          </div>
+        </div>
+
+
+
+      </div>
+      <div class="pf-v5-c-login__main-footer">
+        <!-- template: login.ftl -->
+
+
+      </div>
+    </main>
+
+  </div>
+</div>
+</html>
\ No newline at end of file
diff --git a/pkg/provider/keycloak/keycloak.go b/pkg/provider/keycloak/keycloak.go
index 77d3e9b..91ec693 100644
--- a/pkg/provider/keycloak/keycloak.go
+++ b/pkg/provider/keycloak/keycloak.go
@@ -32,9 +32,11 @@ type Client struct {
 	authErrorValidator *authErrorValidator
 }
 
-const (
-	DefaultAuthErrorElement = "span#input-error"
-	DefaultAuthErrorMessage = "Invalid username or password."
+var (
+	defaultAuthErrorElementV1 = "span#input-error"
+	defaultAuthErrorElementV2 = "div#input-error-username"
+	DefaultAuthErrorElement   = fmt.Sprintf("%s, %s", defaultAuthErrorElementV1, defaultAuthErrorElementV2)
+	DefaultAuthErrorMessage   = "Invalid username or password."
 )
 
 type authErrorValidator struct {
@@ -96,7 +98,7 @@ func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error)
 }
 
 func (kc *Client) doAuthenticate(authCtx *authContext, loginDetails *creds.LoginDetails) (string, error) {
-	authSubmitURL, authForm, err := kc.getLoginForm(loginDetails)
+	authSubmitURL, authForm, authCookies, err := kc.getLoginForm(loginDetails)
 	if err != nil {
 		return "", errors.Wrap(err, "error retrieving login form from idp")
 	}
@@ -129,18 +131,16 @@ func (kc *Client) doAuthenticate(authCtx *authContext, loginDetails *creds.Login
 		if err != nil {
 			return "", errors.Wrap(err, "could not extract Webauthn parameters")
 		}
-
 		webauthnSubmitURL, err := extractSubmitURL(doc)
 		if err != nil {
 			return "", errors.Wrap(err, "unable to locate IDP Webauthn form submit URL")
 		}
 
-		doc, err = kc.postWebauthnForm(webauthnSubmitURL, credentialIDs, challenge, rpId)
+		doc, err = kc.postWebauthnForm(webauthnSubmitURL, credentialIDs, challenge, rpId, authCookies)
 		if err != nil {
 			return "", errors.Wrap(err, "error posting Webauthn form")
 		}
 	}
-
 	samlResponse, err := extractSamlResponse(doc)
 	if err != nil && authCtx.authenticatorIndexValid && passwordValid(doc, kc.authErrorValidator) {
 		return kc.doAuthenticate(authCtx, loginDetails)
@@ -185,22 +185,58 @@ func extractWebauthnParameters(doc *goquery.Document) (credentialIDs []string, c
 	return credentialIDs, challenge, rpID, nil
 }
 
-func (kc *Client) getLoginForm(loginDetails *creds.LoginDetails) (string, url.Values, error) {
+func (kc *Client) getLoginForm(loginDetails *creds.LoginDetails) (string, url.Values, []*http.Cookie, error) {
 
 	res, err := kc.client.Get(loginDetails.URL)
+
 	if err != nil {
-		return "", nil, errors.Wrap(err, "error retrieving form")
+		return "", nil, nil, errors.Wrap(err, "error retrieving form")
 	}
 
 	doc, err := goquery.NewDocumentFromReader(res.Body)
 	if err != nil {
-		return "", nil, errors.Wrap(err, "failed to build document from response")
+		return "", nil, nil, errors.Wrap(err, "failed to build document from response")
 	}
 
+	if loginDetails.KCBroker != "" {
+		log.Printf("Attempting to parse federation chain using keycloak broker %v", loginDetails.KCBroker)
+		KCFederationID := "#social-" + loginDetails.KCBroker
+		var path string
+		doc.Find(KCFederationID).Each(func(i int, s *goquery.Selection) {
+			href, ok := s.Attr("href")
+			if !ok {
+				return
+			}
+			path = href
+		})
+		url, err := url.Parse(loginDetails.URL)
+		if err != nil {
+			return "", nil, nil, errors.Wrap(err, "error parsing url for federation")
+		}
+		new_url := "https://" + url.Hostname() + path
+		req, err := http.NewRequest("GET", new_url, nil)
+		if err != nil {
+			return "", nil, nil, errors.Wrap(err, "error building federated request")
+		}
+		for _, cookie := range res.Cookies() {
+			req.AddCookie(cookie)
+			// log.Println("added cookie: %v to request", cookie)
+		}
+		res2, err := kc.client.Get(new_url)
+		if err != nil {
+			return "", nil, nil, errors.Wrap(err, "error retrieving federated form")
+		}
+		doc2, err := goquery.NewDocumentFromReader(res2.Body)
+		if err != nil {
+			return "", nil, nil, errors.Wrap(err, "failed to build federated document from response")
+		}
+		doc = doc2
+		res = res2
+	}
 	if res.StatusCode == http.StatusUnauthorized {
 		authSubmitURL, err := extractSubmitURL(doc)
 		if err != nil {
-			return "", nil, errors.Wrap(err, "unable to locate IDP authentication form submit URL")
+			return "", nil, nil, errors.Wrap(err, "unable to locate IDP authentication form submit URL")
 		}
 		loginDetails.URL = authSubmitURL
 		return kc.getLoginForm(loginDetails)
@@ -211,13 +247,13 @@ func (kc *Client) getLoginForm(loginDetails *creds.LoginDetails) (string, url.Va
 	doc.Find("input").Each(func(i int, s *goquery.Selection) {
 		updateKeyCloakFormData(authForm, s, loginDetails)
 	})
-
+	authCookies := res.Cookies()
 	authSubmitURL, err := extractSubmitURL(doc)
 	if err != nil {
-		return "", nil, errors.Wrap(err, "unable to locate IDP authentication form submit URL")
+		return "", nil, nil, errors.Wrap(err, "unable to locate IDP authentication form submit URL")
 	}
 
-	return authSubmitURL, authForm, nil
+	return authSubmitURL, authForm, authCookies, nil
 }
 
 func (kc *Client) postLoginForm(authSubmitURL string, authForm url.Values) ([]byte, error) {
@@ -279,9 +315,8 @@ func (kc *Client) postTotpForm(authCtx *authContext, totpSubmitURL string, doc *
 	return doc, nil
 }
 
-func (kc *Client) postWebauthnForm(webauthnSubmitURL string, credentialIDs []string, challenge, rpId string) (*goquery.Document, error) {
+func (kc *Client) postWebauthnForm(webauthnSubmitURL string, credentialIDs []string, challenge, rpId string, cookies []*http.Cookie) (*goquery.Document, error) {
 	webauthnForm := url.Values{}
-
 	var assertion *okta.SignedAssertion
 	var pickedCredentialID string
 	for i, credentialID := range credentialIDs {
@@ -326,14 +361,14 @@ func (kc *Client) postWebauthnForm(webauthnSubmitURL string, credentialIDs []str
 	webauthnForm.Set("credentialId", pickedCredentialID)
 	webauthnForm.Set("userHandle", "")
 	webauthnForm.Set("error", "")
-
 	req, err := http.NewRequest("POST", webauthnSubmitURL, strings.NewReader(webauthnForm.Encode()))
 	if err != nil {
 		return nil, errors.Wrap(err, "error building MFA request")
 	}
-
 	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
-
+	for _, cookie := range cookies {
+		req.AddCookie(cookie)
+	}
 	res, err := kc.client.Do(req)
 	if err != nil {
 		return nil, errors.Wrap(err, "error retrieving content")
@@ -343,7 +378,6 @@ func (kc *Client) postWebauthnForm(webauthnSubmitURL string, credentialIDs []str
 	if err != nil {
 		return nil, errors.Wrap(err, "error reading webauthn form response")
 	}
-
 	return doc, nil
 }
 
@@ -366,7 +400,6 @@ func extractSubmitURL(doc *goquery.Document) (string, error) {
 		}
 		submitURL = action
 	})
-
 	if submitURL == "" {
 		return "", fmt.Errorf("unable to locate form submit URL")
 	}
@@ -377,7 +410,6 @@ func extractSubmitURL(doc *goquery.Document) (string, error) {
 func extractSamlResponse(doc *goquery.Document) (string, error) {
 	var samlAssertion = ""
 	var err = fmt.Errorf("unable to locate saml response field")
-
 	doc.Find("input").Each(func(i int, s *goquery.Selection) {
 		name, ok := s.Attr("name")
 		if ok && name == "SAMLResponse" {



Test patch:
diff --git a/pkg/provider/keycloak/keycloak_test.go b/pkg/provider/keycloak/keycloak_test.go
index 8ff6486..abe1810 100644
--- a/pkg/provider/keycloak/keycloak_test.go
+++ b/pkg/provider/keycloak/keycloak_test.go
@@ -36,7 +36,7 @@ func TestClient_getLoginForm(t *testing.T) {
 	kc := Client{client: &provider.HTTPClient{Client: http.Client{}, Options: opts}}
 	loginDetails := &creds.LoginDetails{URL: ts.URL, Username: "test", Password: "test123"}
 
-	submitURL, authForm, err := kc.getLoginForm(loginDetails)
+	submitURL, authForm, authCookies, err := kc.getLoginForm(loginDetails)
 	require.Nil(t, err)
 	require.Equal(t, exampleLoginURL, submitURL)
 	require.Equal(t, url.Values{
@@ -44,6 +44,8 @@ func TestClient_getLoginForm(t *testing.T) {
 		"password": []string{"test123"},
 		"login":    []string{"Log in"},
 	}, authForm)
+	require.Equal(t, []*http.Cookie([]*http.Cookie{}), authCookies)
+
 }
 
 func TestClient_getLoginFormTryAnotherWay(t *testing.T) {
@@ -59,7 +61,7 @@ func TestClient_getLoginFormTryAnotherWay(t *testing.T) {
 	kc := Client{client: &provider.HTTPClient{Client: http.Client{}, Options: opts}}
 	loginDetails := &creds.LoginDetails{URL: ts.URL, Username: "test", Password: "test123"}
 
-	submitURL, authForm, err := kc.getLoginForm(loginDetails)
+	submitURL, authForm, authCookies, err := kc.getLoginForm(loginDetails)
 	require.Nil(t, err)
 	require.Equal(t, exampleLoginURL, submitURL)
 	require.Equal(t, url.Values{
@@ -67,6 +69,7 @@ func TestClient_getLoginFormTryAnotherWay(t *testing.T) {
 		"password": []string{"test123"},
 		"login":    []string{"Log in"},
 	}, authForm)
+	require.Equal(t, []*http.Cookie([]*http.Cookie{}), authCookies)
 }
 
 func TestClient_getLoginFormRedirect(t *testing.T) {
@@ -93,7 +96,7 @@ func TestClient_getLoginFormRedirect(t *testing.T) {
 	kc := Client{client: &provider.HTTPClient{Client: http.Client{}, Options: opts}}
 	loginDetails := &creds.LoginDetails{URL: ts.URL, Username: "test", Password: "test123"}
 
-	submitURL, authForm, err := kc.getLoginForm(loginDetails)
+	submitURL, authForm, authCookies, err := kc.getLoginForm(loginDetails)
 	require.Nil(t, err)
 	require.Equal(t, 2, count)
 	require.Equal(t, exampleLoginURL, submitURL)
@@ -102,6 +105,7 @@ func TestClient_getLoginFormRedirect(t *testing.T) {
 		"password": []string{"test123"},
 		"login":    []string{"Log in"},
 	}, authForm)
+	require.Equal(t, []*http.Cookie([]*http.Cookie{}), authCookies)
 }
 
 func TestClient_postLoginForm(t *testing.T) {
@@ -303,6 +307,7 @@ func TestClient_CustomizeAuthErrorValidator_CustomSetup(t *testing.T) {
 	require.Equal(t, authErrorValidator.httpMessageRE.String(), ErrMessage1+"|"+ErrMessage2)
 	require.Equal(t, authErrorValidator.httpElement, httpElement)
 }
+
 func TestClient_passwordValid_DefaultValidator(t *testing.T) {
 	// Test with the default auth error message and the default HTTP element
 	idpAccount := cfg.IDPAccount{
@@ -312,13 +317,25 @@ func TestClient_passwordValid_DefaultValidator(t *testing.T) {
 	authErrorValidator, err := CustomizeAuthErrorValidator(&idpAccount)
 	require.Nil(t, err)
 
-	data, err := os.ReadFile("example/authError-invalidPassword.html")
-	require.Nil(t, err)
+	tCases := []struct {
+		name string
+		file string
+	}{
+		{name: "v1", file: "example/authError-invalidPassword.html"},
+		{name: "v2", file: "example/authError-invalidPassword-v2.html"},
+	}
 
-	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
-	require.Nil(t, err)
+	for _, tc := range tCases {
+		t.Run(tc.name, func(t *testing.T) {
+			data, err := os.ReadFile(tc.file)
+			require.Nil(t, err)
 
-	require.Equal(t, passwordValid(doc, authErrorValidator), false)
+			doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
+			require.Nil(t, err)
+
+			require.Equal(t, passwordValid(doc, authErrorValidator), false)
+		})
+	}
 }
 
 func TestClient_passwordValid_CustomValidator(t *testing.T) {