RSS

How to use AWS CDK with AWS SSO & Profiles

AWS CDK doesn’t integrate with AWS CLI Profiles and AWS SSO login. Here is a workaround.
Share this page:

Issue: We can’t use CDK with Multiple AWS Accounts and AWS SSO

AWS Cloud Development Kit, which I’ve been using since 2019, supports:

  • Multiple Environments.
  • Multiple Regions.
  • Multiple AWS Accounts.

Bunch of colors

However, if you’re using AWS SSO for Authentication, as of today - there is no integration with CDK. This means that if you manually (or automatically using aws sso configure) define the AWS CLI profile, as shown below:

~ more .aws/config
[default]
region = eu-west-1
output = yaml
AWS_SDK_LOAD_CONFIG = false

[profile dev]
sso_start_url = https://matscloud.awsapps.com/start
sso_region = eu-west-1
sso_account_id = YOUR_DEV_ACCOUNT
sso_role_name = ROLE_NAME
region = eu-west-1
output = yaml

[profile test]
sso_start_url = https://matscloud.awsapps.com/start
sso_region = eu-west-1
sso_account_id = YOUR_TEST_ACCOUNT
sso_role_name = ROLE_NAME
region = eu-west-1
output = yaml

…and try to simply create a CDK Stack and Deploy it using one of the profiles, you’ll get an error:

(.env)unicornpitch git:(master)cdk --profile test deploy
Need to perform AWS calls for account XXXXXXXXXXX, but no credentials have been configured.

Workaround: AWS SSO Credentials File Updater for AWS SDKs

There’s been a few possible workarounds out there, but one of my favorites is by Brian, or “sgtoj”, and I’ve modified his work a bit to make it work for most use cases.

Install Dependencies

This script simplifies updating the AWS credentials file (i.e., ~/.aws/credentials) for AWS SSO users. It will update the AWS credentials file by adding/updating the specified profile credentials using the AWS CLI v2 cached SSO login.

Step 1: Install AWS CLI v2

MacOS commands are presented below, if you’re using something else, use the official documentation:

curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg ./AWSCLIV2.pkg -target /
...
which aws
/usr/local/bin/aws 
aws --version
aws-cli/2.0.23 Python/3.7.4 Darwin/18.7.0 botocore/2.0.0

Step 2: Configure AWS SSO using Profiles

Note your AWS SSO URL, Account Numbers, and the name of IAM Role you’re assuming in these accounts. You can use the official AWS documentation, but basically you need to modify your `` file to look something like this (add as many profiles as you need):

[profile test]
sso_start_url = https://matscloud.awsapps.com/start
sso_region = eu-west-1
sso_account_id = YOUR_TEST_ACCOUNT
sso_role_name = ROLE_NAME
region = eu-west-1
output = yaml

Before you can actually use the profile, you need to log in. The following command will redirect you to the browser, where you’ll input your credentials, and hopefully MFA code, if you’re smart enough to be using MFA

aws sso login --profile test

Note that if you’re using the same SSO to log into different accounts, you will only need to do sso login once, and you’ll be able to execute AWS CLI commands for all your associated profiles.

To make sure everything worked, check list the s3 buckets using different profiles:

aws s3 ls --profile test

Step 3: Install boto3

I’ll assume you already have Python 3 installed, as you’re using CDK. If not, brew install python3 is your friend.

Since we’ll be using AWS Python SDK for the script, you’ll need to install boto3 using pip:

pip install boto3

Execute the Python SSO Script

Create a file called aws_sso.py, and copy the following content. You don’t need to change anything to set your environment, just copy the exact same file. This is a script we’ll use to update the credentials file with the information about our profile. I’ll use a profile called test.

#!/usr/bin/env python3

import json
import os
import sys
from configparser import ConfigParser
from datetime import datetime
from pathlib import Path

import boto3

AWS_CONFIG_PATH = f"{Path.home()}/.aws/config"
AWS_CREDENTIAL_PATH = f"{Path.home()}/.aws/credentials"
AWS_SSO_CACHE_PATH = f"{Path.home()}/.aws/sso/cache"
AWS_DEFAULT_REGION = "eu-west-1"

def set_profile_credentials(profile_name):
    profile = get_aws_profile(profile_name)
    cache_login = get_sso_cached_login(profile)
    credentials = get_sso_role_credentials(profile, cache_login)
    update_aws_credentials(profile_name, profile, credentials)

def get_sso_cached_login(profile):
    file_paths = list_directory(AWS_SSO_CACHE_PATH)
    time_now = datetime.now()
    for file_path in file_paths:
        data = load_json(file_path)
        if data is None:
            continue
        if data.get("startUrl") != profile["sso_start_url"]:
            continue
        if data.get("region") != profile["sso_region"]:
            continue
        if time_now > parse_timestamp(data.get("expiresAt", "1970-01-01T00:00:00UTC")):
            continue
        return data
    raise Exception("Current cached SSO login is expired or invalid")


def get_sso_role_credentials(profile, login):
    client = boto3.client("sso", region_name=profile["sso_region"])
    response = client.get_role_credentials(
        roleName=profile["sso_role_name"],
        accountId=profile["sso_account_id"],
        accessToken=login["accessToken"],
    )
    return response["roleCredentials"]

def get_aws_profile(profile_name):
    config = read_config(AWS_CONFIG_PATH)
    profile_opts = config.items(f"profile {profile_name}")
    profile = dict(profile_opts)
    return profile

def update_aws_credentials(profile_name, profile, credentials):
    region = profile.get("region", AWS_DEFAULT_REGION)
    config = read_config(AWS_CREDENTIAL_PATH)
    if config.has_section(profile_name):
        config.remove_section(profile_name)
    config.add_section(profile_name)
    config.set(profile_name, "region", region)
    config.set(profile_name, "aws_access_key_id", credentials["accessKeyId"])
    config.set(profile_name, "aws_secret_access_key", credentials["secretAccessKey"])
    config.set(profile_name, "aws_session_token", credentials["sessionToken"])
    write_config(AWS_CREDENTIAL_PATH, config)

def list_directory(path):
    file_paths = []
    if os.path.exists(path):
        file_paths = Path(path).iterdir()
    file_paths = sorted(file_paths, key=os.path.getmtime)
    file_paths.reverse()  # sort by recently updated
    return [str(f) for f in file_paths]

def load_json(path):
    try:
        with open(path) as context:
            return json.load(context)
    except ValueError:
        pass  # ignore invalid json

def parse_timestamp(value):
    return datetime.strptime(value, "%Y-%m-%dT%H:%M:%SUTC")

def read_config(path):
    config = ConfigParser()
    config.read(path)
    return config

def write_config(path, config):
    with open(path, "w") as destination:
        config.write(destination)

def script_handler(args):
    profile_name = "default"
    if len(args) == 2:
        profile_name = args[1]
    set_profile_credentials(profile_name)

if __name__ == "__main__":
    script_handler(sys.argv)

Once you’ve got the file, run it to create credentials for your test profile. Repeat this for all the profiles within your AWS Config file:

python3 aws_sso.py test

Deploy CDK Stack

Within your app.py you’ll need to set the environment.

#!/usr/bin/env python3

from aws_cdk import core
from aws_cdk.core import Tag

from unicornpitch.unicornpitch_stack import UnicornpitchStack

app = core.App()

# Environment, Stack name and region
test = core.Environment(account="ACCOUNTNAME", region="eu-west-1")
UnicornpitchStack(app, "UnicornPitch", env=test)

# TAGs
Tag.add(app,"global:project", "unicorn")
Tag.add(app,"global:bu", "cloud")
Tag.add(app,"global:environment", "test")

app.synth()

If you now try to deploy your stack, specifying the profile test, it will all work out:

CDK OK

What if you have 2 different environments within a single CDK?

Let’s say you have 3 TAGs you want to use for everything and 2 environments, test and dev, with 2 different accounts:

#!/usr/bin/env python3

from aws_cdk import core
from aws_cdk.core import Tag

from unicornpitch.unicornpitch_stack import UnicornpitchStack

app = core.App()

# Stack name and region
test = core.Environment(account="ACCOUNT1NUMBER", region="eu-west-1")
dev = core.Environment(account="ACCOUNT2NUMBER", region="eu-west-2")

# Stack name and region
UnicornpitchStack(app, "UnicornPitchTest", env=test)
UnicornpitchStack(app, "UnicornPitchDev", env=dev)

app.synth()

When you deploy your stack, be sure to specify both, stack name and the profile name, meaning:

cdk deploy UnicornPitchTest --profile test
cdk deploy UnicornPitchDev --profile dev

IMPORTANT: Get uset to log in using your profile, run credential population, and then operate on your CDK stack, otherwise you will get a Token error, as shown below: