Part 3 - Finding A Better Way™


New Story:

We’d like to login with our MFA token exactly once per work day, no more.

We’d like to use that single MFA session for cross-account access to a variety of accounts, without being additionally prompted for MFA.

We’d like this all to work with SDKs like boto3 just as transparently as it does with the CLI.

In short, we’d like our CLI experience to Not Suck

Where we’re going we don’t need roads!

The sad truth is that the current state of the CLI tools and SDKs simply don’t afford such a user-friendly experience out of the box. So as much as we tried to avoid it, we’ll need to build a flux capacitor a helper script if we want to CLI at 88 miles per hour without going mad.

The Jump Profile

Our login profile doesn’t use MFA. Our cheddar/brie/havarti profiles can’t work without MFA. The SDKs won’t use or save cached credentials to disk. What we need is a bridge between login and the cheeses. What we need is a Jump Profile™.

Taking a hint from that painful AWS documentation page, we’re going to use the GetSessionToken API to generate our MFA-flagged temporary credentials. And we’re going to save them to our ~/.aws/credentials file. Just like that sad video said to do in the first place. Dammit, I hate when they’re right. :(

BUT! We are NOT going to use that profile directly, haha take that AWS documentation video guy! And we sure as heck aren’t going to manually copy/paste all that line noise back and forth every time. We are software professionals after all, not barbarians.

Instead we are going to use this temporary profile, what I’ll call a Jump Profile, as a bridge between our non-MFA login and our MFA-only account cheese profiles. Our ~/.aws/config for this will look like:

[profile login]
region = us-east-1
output = json
[profile login-mfa]
region = us-east-1
output = json
[profile cheddar]
source_profile = login-mfa
role_arn = arn:aws:iam::111111111111:role/TestRole
role_session_name = test-user
region = us-east-1
output = json
[profile brie]
source_profile = login-mfa
role_arn = arn:aws:iam::222222222222:role/TestRole
role_session_name = test-user
region = us-east-1
output = json
[profile havarti]
source_profile = login-mfa
role_arn = arn:aws:iam::333333333333:role/TestRole
role_session_name = test-user
region = us-east-1
output = json

Note how the source_profile values of our account cheese profiles now reference the login-mfa profile instead of the login profle. We have also removed all the mfa_serial options entirely because now we’ll be handling MFA now outside of the config files.

How it all works now is this:

  1. The user runs a helper script that uses her login profile credentials, plus a supplied MFA token, to generate MFA-flagged session credentials and save them to the login-mfa profile in ~/.aws/credentials
  2. The real profiles (cheddar, etc) all use role_arn combined with the credentials of the login-mfa profile to AssumeRole for cross-account access. This works because the login-mfa credentials are flagged as MFA = true.
  3. The user doesn’t need to worry about the login-mfa credentials expiring for 12 hours (by default and optionally upto 36 hours).
  4. The various AssumeRole credentials will still expire after 1 hour, but since both the CLI and SDKs are happy to refresh them automatically and transparently, our user doesn’t even notice.

The Helper Script

To glue all this together we need a modest helper script to bridge the gap. Any SDK supported language would do, but I’ve written this one up in Python. I’ve chosen Python here primarily because since the CLI is built on Python and boto3 I can be assured this will be supported wherever it’s useful. This avoids needing to distribute language runtimes, SDKs, etc. The script is as brief and portable as I could reasonably make it.

As always, this code is provided strictly asis. Use at your own risk, no warranty is neither expressed or implied, get your pets spayed or neutered, and don’t run with scissors.

Copy/paste it from below or download it from GitHub.

#!/bin/python
#
# Utility script to use get-session-token with MFA to create and save
# temporary credentials for use as a jump profile for MFA multi-account use
#
# Profiles needed       Default     Description
#   --login-profile     login       Valid, long lived Access Key credentials
#   --jump-profile      login-mfa   Temporary, MFA backed session credentials
#
#   <other>     - Any MFA profile that uses login-mfa as a source_profile
#
# BSD 2-Clause License
# 
# Copyright (c) 2018, Byron
# All rights reserved.
# 
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# 
# * Redistributions of source code must retain the above copyright notice, this
#   list of conditions and the following disclaimer.
# 
# * Redistributions in binary form must reproduce the above copyright notice,
#   this list of conditions and the following disclaimer in the documentation
#   and/or other materials provided with the distribution.
# 
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

from __future__ import print_function
import boto3
from getpass import getpass
from subprocess import call
from pprint import pprint
import argparse

def main():
    args = get_args()

    # Get some details about our login profile.  This also verifies it's working
    sts_client  = boto3.session.Session(profile_name = args.login_profile).client('sts')
    login_arn   = sts_client.get_caller_identity()['Arn']
    jump_mfa    = login_arn.replace(':user/', ':mfa/')

    # Ask for the MFA token
    print("Using your '{}' profile and MFA to login to AWS".format(args.login_profile))
    mfa_token = getpass("Enter MFA code for {}: ".format(jump_mfa))

    # Get our temporary, MFA-flagged credentials
    temp_creds = sts_client.get_session_token(
        DurationSeconds = args.duration,
        SerialNumber    = jump_mfa,
        TokenCode       = mfa_token,
    )['Credentials']

    print("MFA login succeeded, saving your credentials to your '{}' profile.".format(args.jump_profile))
    print("Your AssumeRole profiles should use the directive 'source_profile = {}' to leverage this identity".format(args.jump_profile))

    # Save our MFA-flagged temporary credentials
    #
    # We shell out here to save our credentials because we know the user has
    # the AWS CLI installed and it knows better than we do where and how to
    # manage the ~/.aws/ files
    call(['aws', '--profile', args.jump_profile, 'configure', 'set', 'aws_access_key_id',       temp_creds['AccessKeyId']])
    call(['aws', '--profile', args.jump_profile, 'configure', 'set', 'aws_secret_access_key',   temp_creds['SecretAccessKey']])
    call(['aws', '--profile', args.jump_profile, 'configure', 'set', 'aws_session_token',       temp_creds['SessionToken']])

    # We're done, this is just some pretty output for the user and verifies
    # the jump profile
    print("Credentials saved.  Your temporary identity is:")
    temp_identity = boto3.session.Session(profile_name = args.jump_profile).client('sts').get_caller_identity()
    temp_identity.pop('ResponseMetadata') # Noise
    pprint(temp_identity, indent = 4)

def get_args():
    _parser = argparse.ArgumentParser()
    _parser.add_argument('--login-profile', type=str, default="login")
    _parser.add_argument('--jump-profile',  type=str, default="login-mfa")
    _parser.add_argument('--duration',      type=int, default=43200)

    return _parser.parse_args()

if __name__ == "__main__":
    main()

Lets take this puppy out for a walk!

With the helper script firmly in hand and saved to aws_login.py, our ~/.aws/config setup as above, and our AccessKey/Secret saved against our login profile from Part 1 of this journey, we try a few commands out:

$ ./aws_login.py
Using your 'login' profile and MFA to login to AWS
Enter MFA code for arn:aws:iam::111111111111:mfa/test-user:
MFA login succeeded, saving your credentials to your 'login-mfa' profile.
Your AssumeRole profiles should use the directive 'source_profile = login-mfa' to leverage this identity
Credentials saved.  Your temporary identity is:
{   u'Account': '111111111111',
    u'Arn': 'arn:aws:iam::111111111111:user/test-user',
    u'UserId': 'AIDBI2UVWQDJ4265ZUQKK'}

$ aws --profile cheddar iam list-users
{
    "Users": [
        {
            "UserName": "test-user",
            "PasswordLastUsed": "2018-02-13T01:12:29Z",
            "CreateDate": "2018-01-29T23:58:26Z",
            "UserId": "AIDBI2UVWQDJ4265ZUQKK",
            "Path": "/",
            "Arn": "arn:aws:iam::111111111111:user/test-user"
        }
    ]
}

$ aws --profile brie iam list-users
{
    "Users": []
}
$ aws --profile havarti iam list-users
{
    "Users": []
}
$ for profile in cheddar brie havarti; do
>    for region in $(aws --profile $profile ec2 describe-regions --query "Regions[][RegionName]" --output text); do
>        export AWS_PROFILE="$profile"
>        export AWS_DEFAULT_REGION="$region"
>        ./some_python_boto3_script.py
>    done
>done
Cheers from profile cheddar, region ap-south-1
Cheers from profile cheddar, region eu-west-3
Cheers from profile cheddar, region eu-west-2
Cheers from profile cheddar, region eu-west-1
Cheers from profile cheddar, region ap-northeast-2
Cheers from profile cheddar, region ap-northeast-1
...
Cheers from profile brie, region ap-south-1
Cheers from profile brie, region eu-west-3
Cheers from profile brie, region eu-west-2
Cheers from profile brie, region eu-west-1
Cheers from profile brie, region ap-northeast-2
Cheers from profile brie, region ap-northeast-1
...
Cheers from profile havarti, region ap-south-1
Cheers from profile havarti, region eu-west-3
Cheers from profile havarti, region eu-west-2
Cheers from profile havarti, region eu-west-1
Cheers from profile havarti, region ap-northeast-2
Cheers from profile havarti, region ap-northeast-1
...

And that’s it. We’re done. A one time ask for our MFA token and we get to securely access all our accounts in any region using either the CLI or API. We won’t be bothered again for hours.

Happy users, happy admins, happy security folks. Whoohoo!

Cons:

This approach isn’t perfect, there are a few significant shortcomings.

  1. We can’t avoid a helper script and all the issues that creates as we described at the end of part 1. Although we did minimize those issues by writing it in Python.

  2. This solution is complex and advanced. It requires advanced understanding of AWS IAM, complex and non-obvious config file setups for the users, training on the custom tools, and significant end user support. There are a lot of ways this can fail.

  3. While we are no longer constantly prompted for MFA (which is awesome!), alas this method will never re-prompt for MFA (which is not awesome). Once the credentials expire we just get errors back, causing our commands/scripts to fail, etc. That’s fine if you’re doing one line commands at the CLI (just call aws_login.py again), but less fine if you’re executing a long running script and it dies 3/4ths of the way through.

Hopefully in the future AWS will see fit to borrow the “device” access model that Azure uses for their CLI where you authorize your device much like HBOGo authorizes your Roku. But until then this works quite well once it’s setup.

Thoughts, Comments, Flames, Love Letters