Repository: aws/aws-cli

Issue:
Provide way to get current credentials (AWS SDKs do not support SSO)
My team has set up AWS SSO and is starting to use `aws sso login` for most of their needs. Everything is working smoothly. However, it appears that AWS SDKs (e.g. Golang apps that call AWS APIs) do not support _reading_ the temporary SSO credentials stored in `~/.aws/cli/cache`/`~/.aws/sso/cache`. (See https://github.com/aws/aws-sdk-go/issues/3186) 

And as a result, my team is blocked from adopting AWS SSO because it only works with the AWS CLI but none one our existing tools.

Ideally, the various language-specific AWS SDKs would be able to pick up on the current AWS SSO credentials seamlessly. Honoring `AWS_PROFILE` or `AWS_DEFAULT_PROFILE` environment variables, and using the same credential lookup algorithm as the CLI. (I assume that's the on the roadmap eventually.)

However, getting that change fixed across all of the AWS SDKs, as well as them upstreamed into tools that rely on them, will take a long time. And it would be nice to unblock my team until then so they can just rely on `aws sso login`.

I can see the credential files on my local disk, e.g. `~/.aws/sso/cache/61368d38a2497e42a24a243072108001849d0b07.json`. But it isn't clear how to map the current set of environment variables to which JSON file to load.

Could the CLI support some way of _returning_ whatever the credentials it is using? e.g.

```
# Return the SSO credentials file for the provided profile, or just use AWS_PROFILE, etc.
aws --profile staging-environment \
    sts get-caller-credentials

# Or even better: write the SSO credentials to ~/.aws/credentials. So other tools
# could just read from it.
aws sso write-credentials-to-disk --profile staging-environment
```

I don't know if there is a better approach here, as I'm do not know the specific differences between credential resolution in the AWS CLI vs. AWS SDKs. But hopefully there is some sort of workaround to make this scenario work?


Discussion:
This is an important feature, please prioritize it. Many of the AWS SDKs do not work with SSO forcing a workaround. Most SDKs **do** support external credential_process handlers via configuration profile. It would be great if `aws sso` could output credentials in the supported format as a one liner. This would eliminate the need for a number of third-party tools that work around this, and the many AWS customers that are rewriting those tools so as not to expose their credentials. 

`aws sso get-profile-credentials --profile profileName` could get the token from cache and return the STS creds for the given profile in the JSON format for credential_process.

Format for credential helpers to export: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html

Example of a 3rd party tool, also has a list of other tools on the README: https://github.com/victorskl/yawsso
Are there any updates on this feature request?
In the meantime, aws-vault v6+ is a nice solution to circumvent this issue in your local environment.

See https://github.com/99designs/aws-vault/pull/549
I've created an npm package for updating the credentials from the command line for any users out there running node https://github.com/ryansonshine/aws-sso-creds-helper
Hi @chrsmith,

Thanks for the request and sorry for the delay. This is also related to #4982. Marking as an SSO feature request. 
I've also written a utility in Python that supports AWS SSO credentials. https://github.com/benkehoe/aws-export-credentials
On the CDK repo they recommend the following wrapper: https://pypi.org/project/aws2-wrap/
Please, please fix this. `aws sso` needs to write `~/.aws/credentials`.
The solution from @m4dc4p has the added benefit of working well with tools like `cdk`, which currently do not support SSO named profiles. Workarounds exist, but this seems like a simple solution to a problem affecting anyone trying to work with SSO
https://github.com/aws/aws-cdk/issues/5455
unfortunately, this is a blocker for moving to SSO for my organization. sad to see it's been 2 years since the issue was brought up. Is there any significant movement or news on this?
@es50678,

SSO support is nearly universal across AWS SDKs today. As of now, all AWS SDKs except C++ support the credentials from SSO login. You can read more about the feature and support across AWS SDKs here:

- https://docs.aws.amazon.com/sdkref/latest/guide/access-sso.html
- https://docs.aws.amazon.com/sdkref/latest/guide/feature-sso-credentials.html

I'm made a couple of posts in a related issue. Specifically these two comments:

- https://github.com/aws/aws-cli/issues/4982#issuecomment-939030331
- https://github.com/aws/aws-cli/issues/4982#issuecomment-939348934

I also [note here](https://github.com/aws/aws-cli/issues/4982#issuecomment-894491454) that exporting credentials of various types remains a desirable feature for users that we should explore further:

> One thing I hear from this is the need to still get out the current set of credentials that a profile would be using, regardless if they come from SSO, an assume role, or even are configured in the credential file itself. This open issue (https://github.com/aws/aws-cli/issues/5261) proposes that specifically for SSO in response to the lack of support for SSO, but it seems to have further value to those who need the credentials in a predictable format for other uses.

I don't have a timeline on when we would have something implemented at this time.

Since my team and I needed this sooner than the merged PR, which I am glad to see in motion. 

Here is my shell function
```
get_me_creds() {
  aws sso get-role-credentials \
    --account-id $(aws configure get sso_account_id --profile ${AWS_PROFILE}) \
    --role-name $(aws configure get sso_role_name --profile ${AWS_PROFILE}) \
    --access-token $(find ~/.aws/sso/cache -type f ! -name "botocore*.json" | xargs jq -r .accessToken) \
    --region $(aws configure get region --profile ${AWS_PROFILE}) |\
  jq -r '.roleCredentials |
      {
        "AWS_ACCESS_KEY_ID": .accessKeyId,
        "AWS_SECRET_ACCESS_KEY": .secretAccessKey,
        "AWS_SESSION_TOKEN": .sessionToken,
        "AWS_CREDENTIALS_EXPIRATION": (.expiration / 1000 | todate)
      } | keys[] as $k | "export \($k)=\(.[$k])"'
}

# AWS_PROFILE=example get_me_creds
```
Thanks @TechIsCool, that function was extremely helpful.  I had to make just a few changes to make it work for me, since I swap between SSO orgs, and some profiles didn't have regions, so I defaulted `us-east-1` in my case.

```bash
aws-sso-creds() {
  local account_id role_name access_token region
  account_id="$(aws configure get sso_account_id --profile ${AWS_PROFILE})"
  role_name="$(aws configure get sso_role_name --profile ${AWS_PROFILE})"
  region="$(aws configure get region --profile ${AWS_PROFILE})"
  access_token="$( \
    \ls -c "${HOME}/.aws/sso/cache/" | grep -v botocore \
    | sort -nr | cut -d' ' -f2 | head -n1 \
    | xargs -I{} jq -r .accessToken ${HOME}/.aws/sso/cache/{}
  )"
  aws sso get-role-credentials \
    --account-id "${account_id}" \
    --role-name "${role_name}" \
    --region "${region:-us-east-1}" \
    --access-token "${access_token}" \
    --no-sign-request \
    --output json \
    | jq -r '.roleCredentials |
      {
        "AWS_ACCESS_KEY_ID": .accessKeyId,
        "AWS_SECRET_ACCESS_KEY": .secretAccessKey,
        "AWS_SESSION_TOKEN": .sessionToken,
        "AWS_CREDENTIALS_EXPIRATION": (.expiration / 1000 | todate)
      } | keys[] as $k | "export \($k)=\(.[$k])"'
}
```

Edited: Made my first version OSX compatible
Based on the examples above, my version initializes AWS env vars using .aws/sso & .aws/cli caches.  It requires good profiles defined in .aws/config file.  Compatible with both Zsh and Bash.

Source this file from your login profile (.zprofile or .bash_profile) or directly from the shell:
`	source ~/setup-aws`

Switch between profiles by exporting AWS_PROFILE with a new value & source this script again.
`	export AWS_PROFILE=staging-env`
`	source ~/setup-aws`

```
export AWS_PROFILE=${AWS_PROFILE:-development}
eval $(aws configure list --profile $AWS_PROFILE > /tmp/aws$$ || \
{ aws sso login > /dev/null; aws configure list --profile $AWS_PROFILE > /tmp/aws$$; } && \
jq -r '.Credentials | {
"AWS_ACCESS_KEY_ID": .AccessKeyId,
"AWS_SECRET_ACCESS_KEY": .SecretAccessKey,
"AWS_SESSION_TOKEN": .SessionToken,
"AWS_EXPIRATION":.Expiration } |
keys[] as $k | "export \($k)=\(.[$k])"' $(grep -l $(grep access_key /tmp/aws$$ | cut -c 32-35) ~/.aws/cli/cache/*))
```

Save that as `~/setup-aws` or wherever you like.
Love all the script sharing here. I used @datfinesoul's as a base, but I wanted to have more confidence finding the SSO cache file instead of using the first found token. The SSO cache file name is a `sha1` based on the `sso_start_url`, so we can just do the same thing in our script.  https://github.com/boto/botocore/blob/b006ff741d12608a9187b873e276abd1fd8eb707/botocore/utils.py#L2364-L2365

This does not automatically set the environment variables. It just outputs the export statements.

```bash
aws_creds() {
  local profile="${1:-${AWS_PROFILE}}"
  local account_id="$(aws configure get sso_account_id --profile "${profile}")" \
  role_name="$(aws configure get sso_role_name --profile "${profile}")" \
  region="$(aws configure get region --profile "${profile}")" \
  start_url="$(aws configure get sso_start_url --profile "${profile}")"

  if [ -z "$start_url" ] ; then
    echo "did not find sso_start_url in profile ${profile}"
    exit 1
  fi

  local cache_file="${HOME}/.aws/sso/cache/$(echo -n "$start_url" | sha1sum | awk '{print $1}').json"

  if [ ! -f "$cache_file" ] ; then
    echo "sso creds not found. are you logged into AWS SSO?"
    echo ;
    echo "aws sso login --profile \"${profile}\""
    exit 1
  fi

  local access_token=$(jq -r .accessToken "${cache_file}")

  aws sso get-role-credentials \
    --account-id "${account_id}" \
    --role-name "${role_name}" \
    --region "${region:-us-east-1}" \
    --access-token "${access_token}" \
    --no-sign-request \
    --output json \
    | jq -r '.roleCredentials |
      {
      "AWS_ACCESS_KEY_ID": .accessKeyId,
      "AWS_SECRET_ACCESS_KEY": .secretAccessKey,
      "AWS_SESSION_TOKEN": .sessionToken,
      "AWS_CREDENTIALS_EXPIRATION": (.expiration / 1000 | todate)
      } | keys[] as $k | "export \($k)=\(.[$k])"'
}
```
@jsifuentes - I am not sure if this is right.. 
```
  local cache_file="${HOME}/.aws/sso/cache/$(echo -n "$start_url" | sha1sum | awk '{print $1}').json"
```

The code you link to is a _great_ find, I don't want to take away from that. But shouldn't you be doing `utf-8` encoding before looking up the file based on SHA1? I have absolutely no idea how to do this in bash... 😂

Anyway, I tried the following... 
1. I tried to `echo` whatever I had in the profile (ex: `echo https://mydomain.awsapps.com/start`)
2. Then piped it to `sha1sum` Ex: `echo https://mydomain.awsapps.com/start | sha1sum` 
3. That is NOT the same as what I had in the `~/.aws/sso/cache` directory

Once I find a solution for this, I'll post here. But you can verify the results fairly easily using this recipe 

https://gchq.github.io/CyberChef/#recipe=Encode_text('UTF-8%20(65001)')SHA1(80)&input=aHR0cHM6Ly9teWRvbWFpbi5hd3NhcHBzLmNvbS9zdGFydA

Hi @vnagendra , I'm not sure I understand your question about the UTF-8 encoding. I don't think it's applicable in this case. Make sure you don't forget to include `-n` in your `echo` command. The `-n` is important because it removes the `\n` that `echo` automatically includes at the end, so if you're excluding `-n`, it will lead to a different sha1 sum.

Hope that helps.
@jsifuentes, Thanks for your help! Using your script as a base, this is what I did. 

My specific use case was there were a couple of Terraform modules. I wanted to upgrade them one by one to the latest version of Terraform. The latest version supports AWS SSO credentials, the older versions don't. I've always used direnv for managing a bunch of environment variables. So I used the following script inside the `.envrc` of the module that _was not_ upgraded. Meaning if you have the newer Terraform (and provider) version, you do *not* need all this circus.

```
source_up

function aws_sso() {
  local start_url=$(aws configure get sso_start_url)
  if [ -z "$start_url" ]; then
    echo "No start URL. Will not continue."
    return 1
  fi
  local fname=$(echo -n $start_url | sha1sum | awk '{print $1}')
  local cache_file=${HOME}/.aws/sso/cache/${fname}.json
  if [ ! -f "$cache_file" ]; then
    echo "No SSO cache found. Logged in?"
    return 1
  fi
  local access_token=$(jq -r .accessToken "${cache_file}")
  aws sso get-role-credentials \
    --account-id "$(aws configure get sso_account_id)" \
    --role-name "$(aws configure get sso_role_name)" \
    --region "$(aws configure get region)" \
    --access-token "${access_token}" \
    --no-sign-request \
    --output json | jq -r '.roleCredentials'
}

local f=$(aws_sso)
if [ "$f" ]; then
	AWS_ACCESS_KEY_ID=$(echo -n $f | jq -r '.accessKeyId')
	AWS_SECRET_ACCESS_KEY=$(echo -n $f | jq -r '.secretAccessKey')
	AWS_SESSION_TOKEN=$(echo -n $f | jq -r '.sessionToken')
	echo "Token Expires $(echo -n $f | jq -r '(.expiration / 1000 | todate)')"
fi
```

Please note, if you use this trick -- you must have the "function aws_sso()" declared inside that specific `.envrc` (inside the module that is not upgraded). In my case the `source_up` was pointing to another `.envrc` (in the parent directory of course) which was just exporting the following 

```
set -a
AWS_PROFILE=sso
AWS_DEFAULT_REGION=us-west-2
```

With this setup, it was fairly simple.. 
```
> cd my-terraform/
direnv: loading ...../.envrc
direnv: export +AWS_DEFAULT_REGION +AWS_PROFILE +ret

> aws sso login
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-west-2.amazonaws.com/

Then enter the code:

DHGN-WKFW
Successfully logged into Start URL: https://something.awsapps.com/start

> cd my-not-upgraded-module/
direnv: loading ..../.envrc
direnv: loading ......./.envrc
Token Expires 2022-08-23T22:25:29Z
direnv: export +AWS_ACCESS_KEY_ID +AWS_DEFAULT_REGION +AWS_PROFILE +AWS_SECRET_ACCESS_KEY +AWS_SESSION_TOKEN +ret
```

Resolving patch:
diff --git a/awscli/customizations/configure/configure.py b/awscli/customizations/configure/configure.py
--- a/awscli/customizations/configure/configure.py
+++ b/awscli/customizations/configure/configure.py
@@ -25,6 +25,8 @@
 from awscli.customizations.configure.importer import ConfigureImportCommand
 from awscli.customizations.configure.listprofiles import ListProfilesCommand
 from awscli.customizations.configure.sso import ConfigureSSOCommand
+from awscli.customizations.configure.exportcreds import \
+    ConfigureExportCredentialsCommand
 
 from . import mask_value, profile_to_section
 
@@ -80,6 +82,8 @@ class ConfigureCommand(BasicCommand):
         {'name': 'import', 'command_class': ConfigureImportCommand},
         {'name': 'list-profiles', 'command_class': ListProfilesCommand},
         {'name': 'sso', 'command_class': ConfigureSSOCommand},
+        {'name': 'export-credentials',
+         'command_class': ConfigureExportCredentialsCommand},
     ]
 
     # If you want to add new values to prompt, update this list here.
diff --git a/awscli/customizations/configure/exportcreds.py b/awscli/customizations/configure/exportcreds.py
new file mode 100644
--- /dev/null
+++ b/awscli/customizations/configure/exportcreds.py
@@ -0,0 +1,263 @@
+# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"). You
+# may not use this file except in compliance with the License. A copy of
+# the License is located at
+#
+#     http://aws.amazon.com/apache2.0/
+#
+# or in the "license" file accompanying this file. This file is
+# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
+# ANY KIND, either express or implied. See the License for the specific
+# language governing permissions and limitations under the License.
+import os
+import io
+import sys
+import csv
+import json
+from datetime import datetime
+from collections import namedtuple
+
+from awscli.customizations.commands import BasicCommand
+from awscli.customizations.exceptions import ConfigurationError
+
+
+# Takes botocore's ReadOnlyCredentials and exposes an expiry_time.
+Credentials = namedtuple(
+    'Credentials', ['access_key', 'secret_key', 'token', 'expiry_time'])
+
+
+def convert_botocore_credentials(credentials):
+    # Converts botocore credentials to our `Credentials` type.
+    frozen = credentials.get_frozen_credentials()
+    expiry_time_str = None
+    # Botocore does not expose an attribute for the expiry_time of temporary
+    # credentials, so for the time being we need to access an internal
+    # attribute to retrieve this info.  We're following up to see if botocore
+    # can make this a public attribute.
+    expiry_time = getattr(credentials, '_expiry_time', None)
+    if expiry_time is not None and isinstance(expiry_time, datetime):
+        expiry_time_str = expiry_time.isoformat()
+    return Credentials(
+        access_key=frozen.access_key,
+        secret_key=frozen.secret_key,
+        token=frozen.token,
+        expiry_time=expiry_time_str,
+    )
+
+
+class BaseCredentialFormatter(object):
+
+    FORMAT = None
+    DOCUMENTATION = ""
+
+    def __init__(self, stream=None):
+        if stream is None:
+            stream = sys.stdout
+        self._stream = stream
+
+    def display_credentials(self, credentials):
+        pass
+
+
+class BasePerLineFormatter(BaseCredentialFormatter):
+
+    _VAR_FORMAT = 'export {var_name}={var_value}'
+
+    def display_credentials(self, credentials):
+        output = (
+            self._format_line('AWS_ACCESS_KEY_ID', credentials.access_key) +
+            self._format_line('AWS_SECRET_ACCESS_KEY', credentials.secret_key))
+        if credentials.token is not None:
+            output += self._format_line('AWS_SESSION_TOKEN', credentials.token)
+        if credentials.expiry_time is not None:
+            output += self._format_line(
+                'AWS_CREDENTIAL_EXPIRATION', credentials.expiry_time)
+        self._stream.write(output)
+
+    def _format_line(self, var_name, var_value):
+        return self._VAR_FORMAT.format(
+            var_name=var_name, var_value=var_value) + '\n'
+
+
+class BashEnvVarFormatter(BasePerLineFormatter):
+
+    FORMAT = 'env'
+    DOCUMENTATION = (
+        "Display credentials as exported shell variables: "
+        "``export AWS_ACCESS_KEY_ID=EXAMPLE``"
+    )
+    _VAR_FORMAT = 'export {var_name}={var_value}'
+
+
+class BashNoExportEnvFormatter(BasePerLineFormatter):
+
+    FORMAT = 'env-no-export'
+    DOCUMENTATION = (
+        "Display credentials as non-exported shell variables: "
+        "``AWS_ACCESS_KEY_ID=EXAMPLE``"
+    )
+    _VAR_FORMAT = '{var_name}={var_value}'
+
+
+class PowershellFormatter(BasePerLineFormatter):
+
+    FORMAT = 'powershell'
+    DOCUMENTATION = (
+        'Display credentials as PowerShell environment variables: '
+        '``$Env:AWS_ACCESS_KEY_ID="EXAMPLE"``'
+    )
+    _VAR_FORMAT = '$Env:{var_name}="{var_value}"'
+
+
+class WindowsCmdFormatter(BasePerLineFormatter):
+
+    FORMAT = 'windows-cmd'
+    DOCUMENTATION = (
+        'Display credentials as Windows cmd environment variables: '
+        '``set AWS_ACCESS_KEY_ID=EXAMPLE``'
+    )
+    _VAR_FORMAT = 'set {var_name}={var_value}'
+
+
+class CredentialProcessFormatter(BaseCredentialFormatter):
+
+    FORMAT = 'process'
+    DOCUMENTATION = (
+        "Display credentials as JSON output, in the schema "
+        "expected by the ``credential_process`` config value."
+        "This enables any library or tool that supports "
+        "``credential_process`` to use the AWS CLI's credential "
+        "resolution process: ``credential_process = aws configure "
+        "export-credentials --profile myprofile``"
+    )
+
+    def display_credentials(self, credentials):
+        output = {
+            'Version': 1,
+            'AccessKeyId': credentials.access_key,
+            'SecretAccessKey': credentials.secret_key,
+        }
+        if credentials.token is not None:
+            output['SessionToken'] = credentials.token
+        if credentials.expiry_time is not None:
+            output['Expiration'] = credentials.expiry_time
+        self._stream.write(
+            json.dumps(output, indent=2, separators=(',', ': '))
+        )
+        self._stream.write('\n')
+
+
+SUPPORTED_FORMATS = {
+    format_cls.FORMAT: format_cls for format_cls in
+    [CredentialProcessFormatter, BashEnvVarFormatter, BashNoExportEnvFormatter,
+     PowershellFormatter, WindowsCmdFormatter]
+}
+
+
+def generate_docs(formats):
+    lines = ['The output format to display credentials.  '
+             'Defaults to `process`.  ', '<ul>']
+    for name, cls in formats.items():
+        line = f'<li>``{name}`` - {cls.DOCUMENTATION} </li>'
+        lines.append(line)
+    lines.append('</ul>')
+    return '\n'.join(lines)
+
+
+class ConfigureExportCredentialsCommand(BasicCommand):
+
+    NAME = 'export-credentials'
+    SYNOPSIS = 'aws configure export-credentials --profile profile-name'
+    DESCRIPTION = (
+        "Export credentials in various formats.  This command will retrieve "
+        "AWS credentials using the AWS CLI's credential resolution process "
+        "and display the credentials in the specified ``--format``. By "
+        "default, the output format is ``process``, which is a JSON format "
+        "that's expected by the credential process feature supported by the "
+        "AWS SDKs and Tools.  This command ignores the global ``--query`` and "
+        "``--output`` options."
+    )
+    ARG_TABLE = [
+        {'name': 'format',
+         'help_text': generate_docs(SUPPORTED_FORMATS),
+         'action': 'store',
+         'choices': list(SUPPORTED_FORMATS),
+         'default': CredentialProcessFormatter.FORMAT},
+    ]
+    _RECURSION_VAR = '_AWS_CLI_PROFILE_CHAIN'
+    # Two levels is reasonable because you might explicitly run
+    # "aws configure export-credentials" with a profile that is configured
+    # with a credential_process of "aws configure export-credentials".
+    # So we'll give one more level of recursion for padding and then
+    # error out when we hit _MAX_RECURSION.
+    _MAX_RECURSION = 4
+
+    def __init__(self, session, out_stream=None, error_stream=None, env=None):
+        super(ConfigureExportCredentialsCommand, self).__init__(session)
+        if out_stream is None:
+            out_stream = sys.stdout
+        if error_stream is None:
+            error_stream = sys.stderr
+        if env is None:
+            env = os.environ
+        self._out_stream = out_stream
+        self._error_stream = error_stream
+        self._env = env
+
+    def _detect_recursion_barrier(self):
+        profile = self._get_current_profile()
+        seen_profiles = self._parse_profile_chain(
+            self._env.get(self._RECURSION_VAR, ''))
+        if len(seen_profiles) >= self._MAX_RECURSION:
+            raise ConfigurationError(
+                f"Maximum recursive credential process resolution reached "
+                f"({self._MAX_RECURSION}).\n"
+                f"Profiles seen: {' -> '.join(seen_profiles)}"
+            )
+        if profile in seen_profiles:
+            raise ConfigurationError(
+                f"Credential process resolution detected an infinite loop, "
+                f"profile cycle: {' -> '.join(seen_profiles + [profile])}\n"
+            )
+
+    def _update_recursion_barrier(self):
+        profile = self._get_current_profile()
+        seen_profiles = self._parse_profile_chain(
+            self._env.get(self._RECURSION_VAR, ''))
+        seen_profiles.append(profile)
+        serialized = self._serialize_to_csv_str(seen_profiles)
+        self._env[self._RECURSION_VAR] = serialized
+
+    def _serialize_to_csv_str(self, profiles):
+        out = io.StringIO()
+        w = csv.writer(out)
+        w.writerow(profiles)
+        serialized = out.getvalue().strip()
+        return serialized
+
+    def _get_current_profile(self):
+        profile = self._session.get_config_variable('profile')
+        if profile is None:
+            profile = 'default'
+        return profile
+
+    def _parse_profile_chain(self, value):
+        result = list(csv.reader([value]))[0]
+        return result
+
+    def _run_main(self, parsed_args, parsed_globals):
+        self._detect_recursion_barrier()
+        self._update_recursion_barrier()
+        try:
+            creds = self._session.get_credentials()
+        except Exception as e:
+            original_msg = str(e).strip()
+            raise ConfigurationError(
+                f"Unable to retrieve credentials: {original_msg}\n")
+        if creds is None:
+            raise ConfigurationError(
+                "Unable to retrieve credentials: no credentials found")
+        creds_with_expiry = convert_botocore_credentials(creds)
+        formatter = SUPPORTED_FORMATS[parsed_args.format](self._out_stream)
+        formatter.display_credentials(creds_with_expiry)


Test patch:
diff --git a/tests/unit/customizations/configure/test_exportcreds.py b/tests/unit/customizations/configure/test_exportcreds.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/customizations/configure/test_exportcreds.py
@@ -0,0 +1,296 @@
+# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"). You
+# may not use this file except in compliance with the License. A copy of
+# the License is located at
+#
+#     http://aws.amazon.com/apache2.0/
+#
+# or in the "license" file accompanying this file. This file is
+# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
+# ANY KIND, either express or implied. See the License for the specific
+# language governing permissions and limitations under the License.
+import json
+import mock
+import io
+from datetime import datetime, timedelta
+
+from dateutil.tz import tzutc
+import pytest
+from botocore.credentials import RefreshableCredentials
+from botocore.credentials import Credentials as StaticCredentials
+from botocore.credentials import ReadOnlyCredentials
+from botocore.session import Session
+
+from awscli.testutils import unittest
+from awscli.customizations.exceptions import ConfigurationError
+from awscli.customizations.configure.exportcreds import (
+    Credentials,
+    convert_botocore_credentials,
+    ConfigureExportCredentialsCommand,
+    BashEnvVarFormatter,
+    BashNoExportEnvFormatter,
+    PowershellFormatter,
+    WindowsCmdFormatter,
+    CredentialProcessFormatter,
+)
+
+
+class JSONValue:
+    def __init__(self, json_str):
+        self.json_str = json_str
+        self.parsed_json = json.loads(json_str)
+
+    def __eq__(self, other):
+        if isinstance(other, str):
+            other_parsed = json.loads(other)
+            return self.parsed_json == other_parsed
+        return self.parsed_json == other
+
+
+@pytest.mark.parametrize(
+    'format_cls, expected', [
+        (BashEnvVarFormatter, (
+            ('export AWS_ACCESS_KEY_ID=access_key\n'
+             'export AWS_SECRET_ACCESS_KEY=secret_key\n'),
+            ('export AWS_ACCESS_KEY_ID=access_key\n'
+             'export AWS_SECRET_ACCESS_KEY=secret_key\n'
+             'export AWS_SESSION_TOKEN=token\n'
+             'export AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'),
+        )),
+        (BashNoExportEnvFormatter, (
+            ('AWS_ACCESS_KEY_ID=access_key\n'
+             'AWS_SECRET_ACCESS_KEY=secret_key\n'),
+            ('AWS_ACCESS_KEY_ID=access_key\n'
+             'AWS_SECRET_ACCESS_KEY=secret_key\n'
+             'AWS_SESSION_TOKEN=token\n'
+             'AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'),
+        )),
+        (PowershellFormatter, (
+            ('$Env:AWS_ACCESS_KEY_ID="access_key"\n'
+             '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n'),
+            ('$Env:AWS_ACCESS_KEY_ID="access_key"\n'
+             '$Env:AWS_SECRET_ACCESS_KEY="secret_key"\n'
+             '$Env:AWS_SESSION_TOKEN="token"\n'
+             '$Env:AWS_CREDENTIAL_EXPIRATION="2023-01-01T00:00:00Z"\n'),
+        )),
+        (WindowsCmdFormatter, (
+            ('set AWS_ACCESS_KEY_ID=access_key\n'
+             'set AWS_SECRET_ACCESS_KEY=secret_key\n'),
+            ('set AWS_ACCESS_KEY_ID=access_key\n'
+             'set AWS_SECRET_ACCESS_KEY=secret_key\n'
+             'set AWS_SESSION_TOKEN=token\n'
+             'set AWS_CREDENTIAL_EXPIRATION=2023-01-01T00:00:00Z\n'),
+        )),
+        (CredentialProcessFormatter, (
+            JSONValue(
+                '{"Version": 1, "AccessKeyId": "access_key", '
+                '"SecretAccessKey": "secret_key"}'),
+            JSONValue(
+                '{"Version": 1, "AccessKeyId": "access_key", '
+                '"SecretAccessKey": "secret_key", "SessionToken": '
+                '"token", "Expiration": "2023-01-01T00:00:00Z"}'),
+        )),
+    ]
+)
+def test_cred_formatter(format_cls, expected):
+    stream = io.StringIO()
+    expected_static, expected_temporary = expected
+    formatter = format_cls(stream)
+
+    # Static case, only access_key/secret_key
+    creds = Credentials('access_key', 'secret_key', None, None)
+    formatter.display_credentials(creds)
+    assert stream.getvalue() == expected_static
+
+    # Refreshable case, access_key/secret_key/token/expiration.
+    # Reset back to an empty stream.
+    stream.truncate(0)
+    stream.seek(0)
+    expiry = '2023-01-01T00:00:00Z'
+    temp_creds = Credentials(
+        'access_key', 'secret_key', 'token', expiry)
+    formatter.display_credentials(temp_creds)
+    assert stream.getvalue() == expected_temporary
+
+
+class TestCanConvertBotocoreCredentials(unittest.TestCase):
+    def test_can_convert_static_creds_with_no_expiry(self):
+        self.assertEqual(
+            convert_botocore_credentials(
+                StaticCredentials('access_key', 'secret_key')
+            ),
+            Credentials('access_key', 'secret_key', token=None,
+                        expiry_time=None)
+        )
+
+    def test_can_convert_creds_with_token_and_no_expiry(self):
+        self.assertEqual(
+            convert_botocore_credentials(
+                StaticCredentials('access_key', 'secret_key', 'token')
+            ),
+            Credentials('access_key', 'secret_key', 'token', expiry_time=None)
+        )
+
+    def test_can_convert_refreshable_with_expiry(self):
+        expiry = datetime.now(tzutc()) + timedelta(hours=12)
+        self.assertEqual(
+            convert_botocore_credentials(
+                RefreshableCredentials(
+                    'access_key',
+                    'secret_key',
+                    'token',
+                    expiry_time=expiry,
+                    refresh_using=None,
+                    method='explicit',
+                )
+            ),
+            Credentials('access_key', 'secret_key', 'token',
+                        expiry_time=expiry.isoformat())
+        )
+
+    def test_no_expiry_time_if_non_datetime_value(self):
+        bad_creds = mock.Mock(spec=StaticCredentials)
+        bad_creds.get_frozen_credentials.return_value = ReadOnlyCredentials(
+            'access_key', 'secret_key', 'token')
+        bad_creds._expiry_time = 'not a datetime'
+        self.assertEqual(
+            convert_botocore_credentials(bad_creds),
+            Credentials('access_key', 'secret_key', 'token',
+                        expiry_time=None)
+        )
+
+
+class TestConfigureExportCredentialsCommand(unittest.TestCase):
+    def setUp(self):
+        self.session = mock.Mock(spec=Session)
+        self.session.emit_first_non_none_response.return_value = None
+        self.out_stream = io.StringIO()
+        self.err_stream = io.StringIO()
+        self.os_env = {}
+        self.session.get_config_variable.return_value = 'default'
+        self.export_creds_cmd = ConfigureExportCredentialsCommand(
+            self.session, self.out_stream, self.err_stream, env=self.os_env)
+        self.global_args = mock.Mock()
+        self.expiry = '2023-01-01T00:00:00Z'
+        self.creds = StaticCredentials('access_key', 'secret_key')
+
+    def test_can_export_creds(self):
+        self.session.get_credentials.return_value = self.creds
+        rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertEqual(rc, 0)
+        self.assertEqual(
+            self.out_stream.getvalue(),
+            JSONValue(
+                '{"Version": 1, "AccessKeyId": "access_key", '
+                '"SecretAccessKey": "secret_key"}')
+        )
+
+    def test_can_export_creds_explicit_format(self):
+        self.session.get_credentials.return_value = self.creds
+        rc = self.export_creds_cmd(
+            args=['--format', 'env'],
+            parsed_globals=self.global_args)
+        self.assertEqual(rc, 0)
+        self.assertEqual(
+            self.out_stream.getvalue(),
+            'export AWS_ACCESS_KEY_ID=access_key\n'
+            'export AWS_SECRET_ACCESS_KEY=secret_key\n'
+        )
+
+    def test_show_error_when_no_cred(self):
+        self.session.get_credentials.return_value = None
+        with pytest.raises(ConfigurationError) as excinfo:
+            self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertIn(
+            'Unable to retrieve credentials', str(excinfo))
+
+    def test_show_error_when_cred_resolution_errors(self):
+        self.session.get_credentials.side_effect = Exception(
+            "resolution failed")
+        with pytest.raises(ConfigurationError) as excinfo:
+            self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertIn(
+            'resolution failed', str(excinfo)
+        )
+
+    def test_can_detect_recursive_resolution(self):
+        self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'default'
+        with pytest.raises(ConfigurationError) as excinfo:
+            self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertIn(
+            'Credential process resolution detected an infinite loop',
+            str(excinfo),
+        )
+
+    def test_nested_calls_not_recursive(self):
+        self.session.get_credentials.return_value = self.creds
+        # If we've seen the profiles foo and bar:
+        self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar'
+        # And we're now on baz, then we shouldn't get an error
+        # because there's no cycle.
+        self.session.get_config_variable.return_value = 'baz'
+        rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertEqual(rc, 0)
+        self.assertEqual(
+            self.out_stream.getvalue(),
+            JSONValue(
+                '{"Version": 1, "AccessKeyId": "access_key", '
+                '"SecretAccessKey": "secret_key"}')
+        )
+
+    def test_nested_calls_with_cycle(self):
+        self.session.get_credentials.return_value = self.creds
+        self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar,baz'
+        self.session.get_config_variable.return_value = 'bar'
+        with pytest.raises(ConfigurationError) as excinfo:
+            self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertIn(
+            'Credential process resolution detected an infinite loop',
+            str(excinfo),
+        )
+
+    def test_handles_comma_char_in_profile_name_no_cycle(self):
+        self.session.get_credentials.return_value = self.creds
+        self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar,baz'
+        # Shouldn't be a cycle, the comma is part of the profile name.
+        self.session.get_config_variable.return_value = 'bar,baz'
+        rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertEqual(rc, 0)
+        self.assertEqual(
+            self.out_stream.getvalue(),
+            JSONValue(
+                '{"Version": 1, "AccessKeyId": "access_key", '
+                '"SecretAccessKey": "secret_key"}')
+        )
+
+    def test_detects_comma_char_with_cycle(self):
+        self.session.get_credentials.return_value = self.creds
+        # To test the round tripping of CSV writing/reading, we'll invoke
+        # the command twice to ensure however it records the profiles
+        # that it detects a cycle with names that need to be escaped/quoted.
+        self.os_env['_AWS_CLI_PROFILE_CHAIN'] = 'foo,bar'
+        self.session.get_config_variable.return_value = 'bar,baz'
+        # First time it succeeds.
+        rc = self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertEqual(rc, 0)
+        # Second time, it detects the cycle.
+        second_invoke = ConfigureExportCredentialsCommand(
+            self.session, self.out_stream, self.err_stream, env=self.os_env)
+        with pytest.raises(ConfigurationError) as excinfo:
+            second_invoke(args=[], parsed_globals=self.global_args)
+        self.assertIn(
+            'Credential process resolution detected an infinite loop',
+            str(excinfo),
+        )
+
+    def test_max_recursion_limit(self):
+        self.session.get_credentials.return_value = self.creds
+        self.os_env['_AWS_CLI_PROFILE_CHAIN'] = ','.join(
+            ['a', 'b', 'c', 'd', 'e', 'f', 'g'])
+        with pytest.raises(ConfigurationError) as excinfo:
+            self.export_creds_cmd(args=[], parsed_globals=self.global_args)
+        self.assertIn(
+            'Maximum recursive credential process resolution reached',
+            str(excinfo),
+        )