Repository: aws/aws-cli

Issue:
[v2] aws sso commands should allow opting out of automatically opening a browser
**Is your feature request related to a problem? Please describe.**
I'm using a remote shell via SSH to a machine that has a GUI (macOS). When performing `aws configure sso` or `aws sso login` it dumps out the url and code to enter into a browser myself. However, the browser has already opened on the remote machine even though I cannot easily access it (nor want to). AWS does not allow me to use the url and code from the console in this scenario to login from my host machine because it was already used by the remote, unseen, browser.

**Describe the solution you'd like**
A CLI flag like `--no-browser` to avoid launching the default browser on the system, allowing me to use the url and code myself manually.

**Describe alternatives you've considered**
A CLI interactive prompt asking whether you'd like to open the default browser. I like this too, and think it should exist before automatically opening another window on my system, but I feel like CLI flags should always exist as well.


Discussion:
Hi @mmerickel, it looks like this request is similar to #5061. If so, I'll link it there and mark it as a duplicate. Thanks!
I think my ticket was a bit more detailed and is actually about machines that **do** have a browser versus machines that don't. However the final feature implemented should definitely satisfy both use cases. The referenced issue also does not mention `aws configure sso`, only `aws sso login`.
@mmerickel This is definitely a reasonable feature request to have something explicit to say "don't open a browser". We went back and forth on whether or not we should open a browser at all, should it be the default, etc. when implementing this initially and opted to try and be smart and always try to open the browser with some fallback text.

Because we use `webbrowser` stdlib module: https://docs.python.org/2/library/webbrowser.html
It should be possible to set the `BROWSER` env var to something else that acts like a no-op as a workaround. For example, you could try using `true`:

```
BROWSER=true aws sso login
```

Let me know if that works for you in the interim.
@joguSD this approach does work perfectly for me. Thank you for that. It appears the code does not know whether it opened the browser or not - if it did then there could be a lot of improved messaging here.
My organization has multiple proxies and gives us multiple domain accounts for different roles, it's almost never the case that my default browser is either logged into the domain account I need to use for the SSO login or using the proxy to meet source IP CIDR firewall rules. I can't get that ENV variable to work on Windows. I have to wait for the CLI to open my default browser and frantically ESC then CTRL-W and hope I caught it in time, then open my other browser that will work for the login.

Even though you have support for using elinks when on a headless connection to a linux machine, I've never seen elinks not fail on cert validation of the SSO login URL. For a while I was having to `sudo chmod -x $(which elinks)` to do the SSO login, but the BROWSER env variable seems to work on Linux. But having a proper set of CLI arguments would be preferred for cross-platform consistenty.
This is an issue for me. For some reason my AV has decided that anytime a browser is launched in WSL it's a 'suspicious behavior'.
It would be easier just have aws/cli (and amplify) just not launch a browser and let me open the browser manually while the antivirus finally figures out the issue on their side.


BROWSER=true does not work with the webbrowser Python module on my system.
https://docs.python.org/3.8/library/webbrowser.html states that it must be a full path.
BROWSER=/usr/bin/true or BROWSER/usr/bin/echo works.
Also see #5533.
> BROWSER=true does not work with the webbrowser Python module on my system.
> https://docs.python.org/3.8/library/webbrowser.html states that it must be a full path.
> BROWSER=/usr/bin/true or BROWSER/usr/bin/echo works.

BROWSER=/usr/bin/echo is great because it produces the full URL for easy one-step copy and paste to a desktop browser.  Thanks for the hint!


> > BROWSER=true does not work with the webbrowser Python module on my system.
> > https://docs.python.org/3.8/library/webbrowser.html states that it must be a full path.
> > BROWSER=/usr/bin/true or BROWSER/usr/bin/echo works.
> 
> BROWSER=/usr/bin/echo is great because it produces the full URL for easy one-step copy and paste to a desktop browser. Thanks for the hint!

That's super useful - not to figure out what it is for Windows users, we have a few of them and this would be quite useful...
> not to figure out what it is for Windows users, 

I would think that just having a real CLI argument like `--no-browser` so that it just prints out the URL and code would be the simplest way. 

That would work for all platforms.
I also just tried setting an environment variable `BROWSER=/usr/bin/echo` and my machine still opens a URL. This is actually possibly causing breakage for some of my users as they have a remote workstation with a GUI, but they might only be SSH'd in and not using the GUI or are using X11 Forwarding.

```
 aws --version
aws-cli/2.0.48 Python/3.7.3 Linux/5.4.0-65-generic exe/x86_64.ubuntu.18
```

Related: https://github.com/aws/aws-cli/issues/5058#issuecomment-775395048 

Setting `DISPLAY` to nothing or empty appears to properly get the AWS CLI to not pop open a browser, though I am not sure if there are any knock on effects to doing that
For Windows:

If you have Cygwin installed, the Linux approach works.
BROWSER=true aws configure sso

Cheers,
Nick
Hi,
any updates on the --no-browser flag?
Feels like jumping through hoops on windows, and no success doing it so far.
Regards 
Tobias

I tried the `BROWSER=false` but not luck

```
docker run --rm -v ~\.aws:/root/.aws -v /c/MYDIRECTORY:/root/tmp -e AWS_CA_BUNDLE="/root/tmp/cacert.pem" -e BROWSER=false -it amazo
n/aws-cli sso login --profile=myprofile1
Attempting to automatically open the SSO authorization page in your default browser.
If the browser does not open or you wish to use a different device to authorize this request, open the following URL:

https://device.sso.us-east-1.amazonaws.com/

Then enter the code:

GZSH-CODE

```
@lmayorga1980 the hack is to use `BROWSER=true` not `false`.

https://github.com/aws/aws-cli/issues/5301#issuecomment-646300069
I tried the `BROWSER=/usr/bin/echo` and got the URL. I guess from there I can use a python wrapper script that does the redirection and submitting the forms.


```
docker run --rm -v ~\.aws:/root/.aws -v /c/MYDIRECTORY:/root/tmp -e AWS_CA_BUNDLE="/root/tmp/cacert.pem" -e BROWSER=/usr/bin/echo -it amazon/aws-cli sso login

https://device.sso.us-east-1.amazonaws.com/?user_code=DQRT-CODE
```
I can confirm that `BROWSER=true` hack works for Debian Buster (no GUI) running in Oracle VirtualBox,

Nevertheless, I still think that adding the `--no-browser` option would make sense for AWS CLI v2.
Any options for a headless(no-browser) environment?
Any workarounds for this?
Our use case is to use AWS SSO as our source of auth and assume IAM roles (Permission Sets) for SaaS-based CICD pipeline. 
The `env BROWSER=/bin/true` works, but I still need to open the link and key in the generated code.
Does anyone know a workaround for this or a "headless" fix is completely impossible?
If so, we'll just use the `sts assume-role` with `--external-id`. 
@dcloud9 I will provide a `gist` of my workaround without `awscli` using `python, selenium, chromedriver`
Would you be able to share the `gist`?
It would be very useful to have an official flag, just for the sake of following the convention that options/flags are documented (e.g. manpage or `help`/`--help`).

The particular pain that this causes for me is that I'm migrating from traditional IAM user access accounts and a new SSO enabled organization accounts. Getting logged out of my IAM session is frustrating.

I'm glad I found the `BROWSER=true` hack.
Would be very valuable to have some "do-not-open" parameter here. My case is that I am using Firefox with "multi account containers" and for practical reasons my SSO-logins are living in a dedicated container.  
@abjoerne I'm using containers and the `BROWSER=true aws sso login` trick works like a charm for me. 👍
Yes thanks, have been using it. Just added the use-case since it is a workaround and AWS have **made assumptions that everyone is using the default browser** for logging into AWS SSO. And you have to remember to use it, and tell others... 

Resolving patch:
diff --git a/awscli/customizations/configure/sso.py b/awscli/customizations/configure/sso.py
--- a/awscli/customizations/configure/sso.py
+++ b/awscli/customizations/configure/sso.py
@@ -29,7 +29,9 @@
 from awscli.customizations.configure import profile_to_section
 from awscli.customizations.configure.writer import ConfigFileWriter
 from awscli.customizations.wizard.ui.selectmenu import select_menu
-from awscli.customizations.sso.utils import do_sso_login
+from awscli.customizations.sso.utils import (
+    do_sso_login, PrintOnlyHandler, LOGIN_ARGS,
+)
 from awscli.formatter import CLI_OUTPUT_FORMATS
 
 
@@ -126,9 +128,10 @@ class ConfigureSSOCommand(BasicCommand):
         'file. By default, ``~/.aws/config``.'
     )
     # TODO: Add CLI parameters to skip prompted values, --start-url, etc.
+    ARG_TABLE = LOGIN_ARGS
 
     def __init__(self, session, prompter=None, selector=None,
-                 config_writer=None, sso_token_cache=None):
+                 config_writer=None, sso_token_cache=None, sso_login=None):
         super(ConfigureSSOCommand, self).__init__(session)
         if prompter is None:
             prompter = PTKPrompt()
@@ -138,6 +141,9 @@ def __init__(self, session, prompter=None, selector=None,
         self._selector = selector
         if config_writer is None:
             config_writer = ConfigFileWriter()
+        if sso_login is None:
+            sso_login = do_sso_login
+        self._sso_login = sso_login
         self._config_writer = config_writer
         self._sso_token_cache = sso_token_cache
 
@@ -295,11 +301,15 @@ def _run_main(self, parsed_args, parsed_globals):
         self._unset_session_profile()
         start_url = self._prompt_for_start_url()
         sso_region = self._prompt_for_sso_region()
-        sso_token = do_sso_login(
+        on_pending_authorization = None
+        if parsed_args.no_browser:
+            on_pending_authorization = PrintOnlyHandler()
+        sso_token = self._sso_login(
             self._session,
             sso_region,
             start_url,
             token_cache=self._sso_token_cache,
+            on_pending_authorization=on_pending_authorization,
         )
 
         # Construct an SSO client to explore the accounts / roles
diff --git a/awscli/customizations/sso/login.py b/awscli/customizations/sso/login.py
--- a/awscli/customizations/sso/login.py
+++ b/awscli/customizations/sso/login.py
@@ -11,7 +11,9 @@
 # ANY KIND, either express or implied. See the License for the specific
 # language governing permissions and limitations under the License.
 from awscli.customizations.commands import BasicCommand
-from awscli.customizations.sso.utils import do_sso_login
+from awscli.customizations.sso.utils import (
+    do_sso_login, PrintOnlyHandler, LOGIN_ARGS,
+)
 from awscli.customizations.utils import uni_print
 from awscli.customizations.exceptions import ConfigurationError
 
@@ -31,7 +33,7 @@ class LoginCommand(BasicCommand):
         'and creating multiple profiles does not allow for multiple users to '
         'be authenticated against the same SSO Start URL.'
     )
-    ARG_TABLE = []
+    ARG_TABLE = LOGIN_ARGS
     _REQUIRED_SSO_CONFIG_VARS = [
         'sso_start_url',
         'sso_region',
@@ -39,10 +41,14 @@ class LoginCommand(BasicCommand):
 
     def _run_main(self, parsed_args, parsed_globals):
         sso_config = self._get_sso_config()
+        on_pending_authorization = None
+        if parsed_args.no_browser:
+            on_pending_authorization = PrintOnlyHandler()
         do_sso_login(
             session=self._session,
             sso_region=sso_config['sso_region'],
             start_url=sso_config['sso_start_url'],
+            on_pending_authorization=on_pending_authorization,
             force_refresh=True
         )
         success_msg = 'Successully logged into Start URL: %s\n'
diff --git a/awscli/customizations/sso/utils.py b/awscli/customizations/sso/utils.py
--- a/awscli/customizations/sso/utils.py
+++ b/awscli/customizations/sso/utils.py
@@ -29,6 +29,18 @@
     os.path.join('~', '.aws', 'sso', 'cache')
 )
 
+LOGIN_ARGS = [
+    {
+        'name': 'no-browser',
+        'action': 'store_true',
+        'default': False,
+        'help_text': (
+            'Disables automatically opening the verfication URL in the '
+            'default browser.'
+        )
+    }
+]
+
 
 def _serialize_utc_timestamp(obj):
     if isinstance(obj, datetime.datetime):
@@ -65,25 +77,55 @@ def open_browser_with_original_ld_path(url):
         webbrowser.open_new_tab(url)
 
 
-class OpenBrowserHandler(object):
+class BaseAuthorizationhandler:
+    def __call__(
+        self, userCode, verificationUri, verificationUriComplete, **kwargs
+    ):
+        # Pending authorization handlers should always take **kwargs in case
+        # the API begins to return new values.
+        raise NotImplementedError("authorization_handler")
+
+
+class PrintOnlyHandler(BaseAuthorizationhandler):
+    def __init__(self, outfile=None):
+        self._outfile = outfile
+
+    def __call__(
+        self, userCode, verificationUri, verificationUriComplete, **kwargs
+    ):
+        opening_msg = (
+            f'Browser will not be automatically opened.\n'
+            f'Please visit the following URL:\n'
+            f'\n{verificationUri}\n'
+            f'\nThen enter the code:\n'
+            f'\n{userCode}\n'
+            f'\nAlternatively, you may visit the following URL which will '
+            f'autofill the code upon loading:'
+            f'\n{verificationUriComplete}\n'
+        )
+        uni_print(opening_msg, self._outfile)
+
+
+class OpenBrowserHandler(BaseAuthorizationhandler):
     def __init__(self, outfile=None, open_browser=None):
         self._outfile = outfile
         if open_browser is None:
             open_browser = webbrowser.open_new_tab
         self._open_browser = open_browser
 
-    def __call__(self, userCode, verificationUri,
-                 verificationUriComplete, **kwargs):
+    def __call__(
+        self, userCode, verificationUri, verificationUriComplete, **kwargs
+    ):
         opening_msg = (
-            'Attempting to automatically open the SSO authorization page in '
-            'your default browser.\nIf the browser does not open or you wish '
-            'to use a different device to authorize this request, open the '
-            'following URL:\n'
-            '\n%s\n'
-            '\nThen enter the code:\n'
-            '\n%s\n'
+            f'Attempting to automatically open the SSO authorization page in '
+            f'your default browser.\nIf the browser does not open or you wish '
+            f'to use a different device to authorize this request, open the '
+            f'following URL:\n'
+            f'\n{verificationUri}\n'
+            f'\nThen enter the code:\n'
+            f'\n{userCode}\n'
         )
-        uni_print(opening_msg % (verificationUri, userCode), self._outfile)
+        uni_print(opening_msg, self._outfile)
         if self._open_browser:
             try:
                 return self._open_browser(verificationUriComplete)


Test patch:
diff --git a/tests/functional/sso/test_login.py b/tests/functional/sso/test_login.py
--- a/tests/functional/sso/test_login.py
+++ b/tests/functional/sso/test_login.py
@@ -124,6 +124,17 @@ def test_login(self):
             expected_token=self.access_token
         )
 
+    def test_login_no_browser(self):
+        self.add_oidc_workflow_responses(self.access_token)
+        stdout, _, _ = self.run_cmd('sso login --no-browser')
+        self.assertIn('Browser will not be automatically opened.', stdout)
+        self.open_browser_mock.assert_not_called()
+        self.assert_used_expected_sso_region(expected_region=self.sso_region)
+        self.assert_cache_contains_token(
+            start_url=self.start_url,
+            expected_token=self.access_token
+        )
+
     def test_login_forces_refresh(self):
         self.add_oidc_workflow_responses(self.access_token)
         self.run_cmd('sso login')
diff --git a/tests/unit/customizations/configure/test_sso.py b/tests/unit/customizations/configure/test_sso.py
--- a/tests/unit/customizations/configure/test_sso.py
+++ b/tests/unit/customizations/configure/test_sso.py
@@ -30,6 +30,7 @@
 from awscli.customizations.configure.sso import ConfigureSSOCommand
 from awscli.customizations.configure.sso import StartUrlValidator
 from awscli.customizations.configure.writer import ConfigFileWriter
+from awscli.customizations.sso.utils import do_sso_login, PrintOnlyHandler
 from awscli.formatter import CLI_OUTPUT_FORMATS
 
 
@@ -149,6 +150,7 @@ def setUp(self):
         }
         self.mock_session = mock.Mock(spec=Session)
         self.mock_session.get_scoped_config.return_value = self.scoped_config
+        self.mock_session.emit_first_non_none_response.return_value = None
         self.mock_session.full_config = self.full_config
         self.mock_session.create_client.return_value = self.sso_client
         self.mock_session.profile = self.profile
@@ -162,26 +164,27 @@ def setUp(self):
         self.writer = mock.Mock(spec=ConfigFileWriter)
         self.prompter = mock.Mock(spec=PTKPrompt)
         self.selector = mock.Mock(spec=select_menu)
-        self.configure_sso = ConfigureSSOCommand(
-            self.mock_session,
-            prompter=self.prompter,
-            selector=self.selector,
-            config_writer=self.writer,
-            sso_token_cache=self.token_cache,
-        )
         self.region = 'us-west-2'
         self.output = 'json'
         self.sso_region = 'us-east-1'
         self.start_url = 'https://d-92671207e4.awsapps.com/start'
         self.account_id = '0123456789'
         self.role_name = 'roleA'
-        self.cached_token_key = '13f9d35043871d073ab260e020f0ffde092cb14b'
         self.expires_at = datetime.now(tzlocal()) + timedelta(hours=24)
         self.access_token = {
             'accessToken': 'access.token.string',
             'expiresAt': self.expires_at,
         }
-        self.token_cache[self.cached_token_key] = self.access_token
+        self.do_sso_login_mock = mock.Mock(spec=do_sso_login)
+        self.do_sso_login_mock.return_value = self.access_token
+        self.configure_sso = ConfigureSSOCommand(
+            self.mock_session,
+            prompter=self.prompter,
+            selector=self.selector,
+            config_writer=self.writer,
+            sso_token_cache=self.token_cache,
+            sso_login=self.do_sso_login_mock,
+        )
 
     def _add_list_accounts_response(self, accounts):
         params = {
@@ -256,6 +259,22 @@ def test_basic_configure_sso_flow(self):
         self.sso_stub.assert_no_pending_responses()
         self.assert_config_updates()
 
+    def test_single_account_single_role_flow_no_browser(self):
+        self._add_prompt_responses()
+        self._add_simple_single_item_responses()
+        with self.sso_stub:
+            self.configure_sso(
+                args=['--no-browser'],
+                parsed_globals=self.global_args,
+            )
+        self.sso_stub.assert_no_pending_responses()
+        self.assert_config_updates()
+        _, _, login_kwargs = self.do_sso_login_mock.mock_calls[0]
+        auth_handler = login_kwargs['on_pending_authorization']
+        self.assertIsInstance(auth_handler, PrintOnlyHandler)
+        # Account / Role should be auto selected if only one is returned
+        self.assertEqual(self.selector.call_count, 0)
+
     def test_single_account_single_role_flow(self):
         self._add_prompt_responses()
         self._add_simple_single_item_responses()
@@ -330,6 +349,7 @@ def test_handles_no_profile(self):
             selector=self.selector,
             config_writer=self.writer,
             sso_token_cache=self.token_cache,
+            sso_login=self.do_sso_login_mock,
         )
         # If there is no profile, it will be prompted for as the last value
         self.prompter.get_value.side_effect = [
@@ -355,6 +375,7 @@ def test_handles_non_existant_profile(self):
             selector=self.selector,
             config_writer=self.writer,
             sso_token_cache=self.token_cache,
+            sso_login=self.do_sso_login_mock,
         )
         self._add_prompt_responses()
         self._add_simple_single_item_responses()
diff --git a/tests/unit/customizations/sso/test_utils.py b/tests/unit/customizations/sso/test_utils.py
--- a/tests/unit/customizations/sso/test_utils.py
+++ b/tests/unit/customizations/sso/test_utils.py
@@ -20,6 +20,7 @@
 from awscli.compat import StringIO
 from awscli.customizations.sso.utils import do_sso_login
 from awscli.customizations.sso.utils import OpenBrowserHandler
+from awscli.customizations.sso.utils import PrintOnlyHandler
 from awscli.customizations.sso.utils import open_browser_with_original_ld_path
 
 
@@ -84,7 +85,7 @@ def test_do_sso_login(self):
         self.assert_on_pending_authorization_called()
 
 
-class TestOpenBrowserHandler(unittest.TestCase):
+class BaseHandlerTest(unittest.TestCase):
     def setUp(self):
         self.stream = StringIO()
         self.user_code = '12345'
@@ -95,17 +96,34 @@ def setUp(self):
             'verificationUri': self.verification_uri,
             'verificationUriComplete': self.verification_uri_complete,
         }
-        self.open_browser = mock.Mock(spec=webbrowser.open_new_tab)
-        self.handler = OpenBrowserHandler(
-            self.stream,
-            open_browser=self.open_browser,
-        )
 
     def assert_text_in_output(self, *args):
         output = self.stream.getvalue()
         for text in args:
             self.assertIn(text, output)
 
+
+class TestPrintOnlyHandler(BaseHandlerTest):
+    def test_prints_message(self):
+        handler = PrintOnlyHandler(self.stream)
+        handler(**self.pending_authorization)
+        self.assert_text_in_output(
+            'Browser will not be automatically opened.',
+            self.user_code,
+            self.verification_uri,
+            self.verification_uri_complete,
+        )
+
+
+class TestOpenBrowserHandler(BaseHandlerTest):
+    def setUp(self):
+        super().setUp()
+        self.open_browser = mock.Mock(spec=webbrowser.open_new_tab)
+        self.handler = OpenBrowserHandler(
+            self.stream,
+            open_browser=self.open_browser,
+        )
+
     def test_call_no_browser(self):
         handler = OpenBrowserHandler(self.stream, open_browser=False)
         handler(**self.pending_authorization)