Repository: Versent/saml2aws

Issue:
Using locally installed browser rather than outdated sanboxed chromium from "playwright-go"
Hi
I'm starting **loving** this tool however there is one little part which isn't great: the sandboxed browser.

1) First the sandboxed chromium run an **outdated** version and indeed **vulnerable**: `90.0.4430.0` on my mac.  

_I've installed sam2aws 1~2 months ago and only run `saml2aws login --idp-account "$aws_app" --profile "$aws_app" --skip-prompt --cache-saml` when my AWS tmp credential are outdated or when I'm assuming another role/other account. saml2aws logs says `Downloaded browsers successfully` so it make me think it try to update this chromium?_ 🤔 

2) Using a private sandboxed chromium force us to re-login each time. 

_My main browser is already connected to the saml identity provider, users are wasting a bit their time (Ex when using multiple aws account and roles)_

**How could we use a locally installed browser?** 🤔 

_Or not using a browser at all with provider like onelogin?_

Thanks for you engagement in this nice tool,
Cheers,
Thom

Hints from issue comments:
@renanrt: I would love that too!@fllaca: I also think this can be a good feature, the tool can open a link in your browser in where you can supply credentials and go back to the CLI.@merusso: In my opinion, using the system's default browser makes more sense than downloading/caching a new browser binary for login.@richard-pianka: This would be a huge improvement, especially given the recent issues with using Google Apps as the identity provider.@GuillaumeRoss: This would be an important features for companies that wish to restrict access to AWS only to corporate machines. For example, using Google's Context-Aware Access (CAA) feature allows one to only let a SAML app work from company owned machines (as well as checking a few other characteristics of the client machine), but it requires using a Chromium/Chrome session with the Endpoint Verification extension, logged in to Google Workspace.

If saml2aws supported using existing Chrome browsers, this would instantly work. Alternatively, there would have to be a way to orchestrate downloading playright, the extension, and ensuring the user logs into Google Workspace at the browser level, making the extension sync, which sounds a lot more brittle.@sahaqaa: I would love see this implemented - because each time entering credentials into sanboxed chromium when i need to switch between accounts / roles in multi-account AWS environment is painful :-(@kbarlowgw: Another +1 here.@dannysauer: Should this be reopened per https://github.com/Versent/saml2aws/pull/816#issuecomment-1848622518?@radekwielonski: I would really love to see that feature implemented.

Solution patch:
diff --git a/Makefile b/Makefile
index 1db3490..9d81af1 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@ ARCH=$(shell uname -m)
 OS?=$(shell uname)
 ITERATION := 1
 
-GOLANGCI_VERSION = 1.45.2
+GOLANGCI_VERSION = 1.53.2
 GORELEASER := $(shell command -v goreleaser 2> /dev/null)
 
 SOURCE_FILES?=$$(go list ./... | grep -v /vendor/)
diff --git a/README.md b/README.md
index 4aefd7e..765d9bf 100644
--- a/README.md
+++ b/README.md
@@ -195,6 +195,10 @@ Flags:
   -a, --idp-account="default"  The name of the configured IDP account. (env: SAML2AWS_IDP_ACCOUNT)
       --idp-provider=IDP-PROVIDER
                                The configured IDP provider. (env: SAML2AWS_IDP_PROVIDER)
+      --browser-type=BROWSER-TYPE
+                               The browser type to use when IDP provider is set to 'Browser'. if not set 'chromium' will be used. (env: SAML2AWS_BROWSER_TYPE) 
+      --browser-executable-path=BROWSER-EXECUTABLE-PATH
+                               The browser full path when IDP provider is set to 'Browser'. If set, no browser download will be performed and the executable path will be used instead. (env: SAML2AWS_BROWSER_EXECUTABLE_PATH)
       --mfa=MFA                The name of the mfa. (env: SAML2AWS_MFA)
   -s, --skip-verify            Skip verification of server certificate. (env: SAML2AWS_SKIP_VERIFY)
       --url=URL                The URL of the SAML IDP server used to login. (env: SAML2AWS_URL)
diff --git a/cmd/saml2aws/main.go b/cmd/saml2aws/main.go
index a638484..1085bb6 100644
--- a/cmd/saml2aws/main.go
+++ b/cmd/saml2aws/main.go
@@ -72,6 +72,8 @@ func main() {
 	app.Flag("config", "Path/filename of saml2aws config file (env: SAML2AWS_CONFIGFILE)").Envar("SAML2AWS_CONFIGFILE").StringVar(&commonFlags.ConfigFile)
 	app.Flag("idp-account", "The name of the configured IDP account. (env: SAML2AWS_IDP_ACCOUNT)").Envar("SAML2AWS_IDP_ACCOUNT").Short('a').Default("default").StringVar(&commonFlags.IdpAccount)
 	app.Flag("idp-provider", "The configured IDP provider. (env: SAML2AWS_IDP_PROVIDER)").Envar("SAML2AWS_IDP_PROVIDER").EnumVar(&commonFlags.IdpProvider, "Akamai", "AzureAD", "ADFS", "ADFS2", "Browser", "GoogleApps", "Ping", "JumpCloud", "Okta", "OneLogin", "PSU", "KeyCloak", "F5APM", "Shibboleth", "ShibbolethECP", "NetIQ", "Auth0")
+	app.Flag("browser-type", "The configured browser type when the IDP provider is set to Browser. if not set 'chromium' will be used. (env: SAML2AWS_BROWSER_TYPE)").Envar("SAML2AWS_BROWSER_TYPE").EnumVar(&commonFlags.BrowserType, "chromium", "firefox", "webkit", "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", "msedge-canary")
+	app.Flag("browser-executable-path", "The configured browser full path when the IDP provider is set to Browser. If set, no browser download will be performed and the executable path will be used instead. (env: SAML2AWS_BROWSER_EXECUTABLE_PATH)").Envar("SAML2AWS_BROWSER_EXECUTABLE_PATH").StringVar(&commonFlags.BrowserExecutablePath)
 	app.Flag("mfa", "The name of the mfa. (env: SAML2AWS_MFA)").Envar("SAML2AWS_MFA").StringVar(&commonFlags.MFA)
 	app.Flag("skip-verify", "Skip verification of server certificate. (env: SAML2AWS_SKIP_VERIFY)").Envar("SAML2AWS_SKIP_VERIFY").Short('s').BoolVar(&commonFlags.SkipVerify)
 	app.Flag("url", "The URL of the SAML IDP server used to login. (env: SAML2AWS_URL)").Envar("SAML2AWS_URL").StringVar(&commonFlags.URL)
diff --git a/pkg/cfg/cfg.go b/pkg/cfg/cfg.go
index d547edf..8051c89 100644
--- a/pkg/cfg/cfg.go
+++ b/pkg/cfg/cfg.go
@@ -39,6 +39,8 @@ type IDPAccount struct {
 	URL                   string `ini:"url"`
 	Username              string `ini:"username"`
 	Provider              string `ini:"provider"`
+	BrowserType           string `ini:"browser_type,omitempty"`            // used by 'Browser' Provider
+	BrowserExecutablePath string `ini:"browser_executable_path,omitempty"` // used by 'Browser' Provider
 	MFA                   string `ini:"mfa"`
 	MFAIPAddress          string `ini:"mfa_ip_address"` // used by OneLogin
 	SkipVerify            bool   `ini:"skip_verify"`
diff --git a/pkg/flags/flags.go b/pkg/flags/flags.go
index 60d8640..1848358 100644
--- a/pkg/flags/flags.go
+++ b/pkg/flags/flags.go
@@ -12,6 +12,8 @@ type CommonFlags struct {
 	ConfigFile            string
 	IdpAccount            string
 	IdpProvider           string
+	BrowserType           string
+	BrowserExecutablePath string
 	MFA                   string
 	MFAIPAddress          string
 	MFAToken              string
@@ -73,6 +75,14 @@ func ApplyFlagOverrides(commonFlags *CommonFlags, account *cfg.IDPAccount) {
 		account.Provider = commonFlags.IdpProvider
 	}
 
+	if commonFlags.BrowserType != "" {
+		account.BrowserType = commonFlags.BrowserType
+	}
+
+	if commonFlags.BrowserExecutablePath != "" {
+		account.BrowserExecutablePath = commonFlags.BrowserExecutablePath
+	}
+
 	if commonFlags.MFA != "" {
 		account.MFA = commonFlags.MFA
 	}
diff --git a/pkg/provider/browser/browser.go b/pkg/provider/browser/browser.go
index 25e5b16..05f9df1 100644
--- a/pkg/provider/browser/browser.go
+++ b/pkg/provider/browser/browser.go
@@ -2,8 +2,10 @@ package browser
 
 import (
 	"errors"
+	"fmt"
 	"net/url"
 	"regexp"
+	"strings"
 
 	"github.com/playwright-community/playwright-go"
 	"github.com/sirupsen/logrus"
@@ -17,7 +19,9 @@ const DEFAULT_TIMEOUT float64 = 300000
 
 // Client client for browser based Identity Provider
 type Client struct {
-	Headless bool
+	BrowserType           string
+	BrowserExecutablePath string
+	Headless              bool
 	// Setup alternative directory to download playwright browsers to
 	BrowserDriverDir string
 	Timeout          int
@@ -26,12 +30,24 @@ type Client struct {
 // New create new browser based client
 func New(idpAccount *cfg.IDPAccount) (*Client, error) {
 	return &Client{
-		Headless:         idpAccount.Headless,
-		BrowserDriverDir: idpAccount.BrowserDriverDir,
-		Timeout:          idpAccount.Timeout,
+		Headless:              idpAccount.Headless,
+		BrowserDriverDir:      idpAccount.BrowserDriverDir,
+		BrowserType:           strings.ToLower(idpAccount.BrowserType),
+		BrowserExecutablePath: idpAccount.BrowserExecutablePath,
+		Timeout:               idpAccount.Timeout,
 	}, nil
 }
 
+// contains checks if a string is present in a slice
+func contains(s []string, str string) bool {
+	for _, v := range s {
+		if v == str {
+			return true
+		}
+	}
+
+	return false
+}
 func (cl *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
 	runOptions := playwright.RunOptions{}
 	if cl.BrowserDriverDir != "" {
@@ -56,10 +72,37 @@ func (cl *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error)
 		Headless: playwright.Bool(cl.Headless),
 	}
 
-	// currently using Chromium as it is widely supported for Identity providers
+	validBrowserTypes := []string{"chromium", "firefox", "webkit", "chrome", "chrome-beta", "chrome-dev", "chrome-canary", "msedge", "msedge-beta", "msedge-dev", "msedge-canary"}
+	if len(cl.BrowserType) > 0 && !contains(validBrowserTypes, cl.BrowserType) {
+		return "", fmt.Errorf("invalid browser-type: '%s', only %s are allowed", cl.BrowserType, validBrowserTypes)
+	}
+
+	if cl.BrowserType != "" {
+		logger.Info(fmt.Sprintf("Setting browser type: %s", cl.BrowserType))
+		launchOptions.Channel = playwright.String(cl.BrowserType)
+	}
+
+	// Default browser is Chromium as it is widely supported for Identity providers,
+	// It can also be set to the other playwright browsers: Firefox and WebKit
+	browserType := pw.Chromium
+	if cl.BrowserType == "firefox" {
+		browserType = pw.Firefox
+	} else if cl.BrowserType == "webkit" {
+		browserType = pw.WebKit
+	}
+
+	// You can set the path to a browser executable to run instead of the playwright-go bundled one. If `executablePath`
+	// is a relative path, then it is resolved relative to the current working directory.
+	// Note that Playwright only works with the bundled Chromium, Firefox or WebKit, use at your own risk. see:
+	if len(cl.BrowserExecutablePath) > 0 {
+		logger.Info(fmt.Sprintf("Setting browser executable path: %s", cl.BrowserExecutablePath))
+		launchOptions.ExecutablePath = &cl.BrowserExecutablePath
+	}
+
+	// currently using the main browsers supported by Playwright: Chromium, Firefox or Webkit
 	//
 	// this is a sandboxed browser window so password managers and addons are separate
-	browser, err := pw.Chromium.Launch(launchOptions)
+	browser, err := browserType.Launch(launchOptions)
 	if err != nil {
 		return "", err
 	}



Test patch:
diff --git a/pkg/provider/browser/browser_test.go b/pkg/provider/browser/browser_test.go
index 1d43b63..d74cc05 100644
--- a/pkg/provider/browser/browser_test.go
+++ b/pkg/provider/browser/browser_test.go
@@ -47,6 +47,45 @@ func TestValidate(t *testing.T) {
 	assert.Equal(t, resp, response)
 }
 
+func TestInvalidBrowserType(t *testing.T) {
+	currentSAMLResponse := getSAMLResponse
+	defer func() {
+		getSAMLResponse = currentSAMLResponse
+	}()
+	getSAMLResponse = fakeSAMLResponse
+	account := &cfg.IDPAccount{
+		BrowserType: "invalid",
+	}
+	client, err := New(account)
+	assert.Nil(t, err)
+	loginDetails := &creds.LoginDetails{
+		URL:             "https://google.com/",
+		DownloadBrowser: true,
+	}
+	_, err = client.Authenticate(loginDetails)
+	assert.Error(t, err)
+	assert.ErrorContains(t, err, "invalid browser-type: 'invalid', only [chromium firefox webkit chrome chrome-beta chrome-dev chrome-canary msedge msedge-beta msedge-dev msedge-canary] are allowed")
+}
+
+func TestInvalidBrowserExecutablePath(t *testing.T) {
+	currentSAMLResponse := getSAMLResponse
+	defer func() {
+		getSAMLResponse = currentSAMLResponse
+	}()
+	getSAMLResponse = fakeSAMLResponse
+	account := &cfg.IDPAccount{
+		BrowserExecutablePath: "FAKEPATH",
+	}
+	client, err := New(account)
+	assert.Nil(t, err)
+	loginDetails := &creds.LoginDetails{
+		URL: "https://google.com/",
+	}
+	_, err = client.Authenticate(loginDetails)
+	assert.Error(t, err)
+	assert.ErrorContains(t, err, "Failed to launch chromium because executable doesn't exist at FAKEPATH")
+}
+
 // Test that if download directory does not have browsers, it fails with expected error message
 func TestNoBrowserDriverFail(t *testing.T) {
 	account := &cfg.IDPAccount{