Skip to content

How to get AWS credentials(temporary) for IAM User, Role and SSO with scripts

20 Nov 2022Series: How does CloudGlance do it
Written by: Rehan van der Merwe

You might be thinking: why use temporary credentials when I already have credentials?

AWS STS is used to exchange long-lived credentials for short-lived credentials that expire within a few hours. This request(s) is highly secure and only requires you to “potentially” expose your long-lived credentials over the wire once. Thereafter, the temporary credential is used to do all the API calls.

To understand the importance of this, let's create two hypothetical scenarios where credentials got exposed/leaked:

  • An application might log them and then they are available in plain text.
  • A developer hardcoded and committed them by accident.

If we used temporary credentials, then by the time these credentials are discovered they would most likely have expired and not need rotation. So you don't need to scrub the logs, you can just leave them as is because those credentials do not work anymore.

Similarly, for the checked-in credentials in your source control, those won’t work anymore and you do not need to go through the effort to clean up commits and hide those secrets.

Let’s look at a few methods on how to obtain temporary credentials. We will show how to configure each method's AWS config and credential file. So that we can use both the AWS CLI or our scripts alongside each other.

We will also talk about the AWS CLI equivalent commands and their shortcomings.

AWS IAM User

The AWS CLI commands, by default, do not exchange the long-lived AWS access keys for temporary credentials before calling AWS services. You have to do this yourself by using the AWS Security Token Service(STS) and then storing the tokens manually for later use.

Don't expect the AWS CLI to use the mfa_serial field on the profile if set, you still need to specify it in the command. You can read more about the issue on GitHub here. Which is a bit disappointing considering how many years MFA for IAM users have been around.

If you want Multi-Factor Authenticated credentials then you have to call STS yourself and specify the mfa_serial again as described by AWS premium support.

The AWS CLI command then returns the credentials to you in JSON format which isn't useful on its own. You can then use them in one of two ways:

  • Export them as environment variables to be used in that single terminal session, example:
    bash
    export AWS_ACCESS_KEY_ID=XXX
    export AWS_SECRET_ACCESS_KEY=YYY
    export AWS_SESSION_TOKEN=ZZZ
  • Set them in the .aws/credentials file and use named profiles (AWS CLI commands with the --profile mfa argument), example:
    text
    [mfa]
    aws_access_key_id = XXX
    aws_secret_access_key = YYY
    aws_session_token = ZZZ

Both are awkward as you need to manually copy and paste the values out of JSON into one of these methods. This is a lot of work for "just" getting Multi-Factor Authenticated credentials for an IAM user.

Let's look at a script to alleviate some of these shortcomings.

AWS config and credential file

Your .aws/config and .aws/credentials need to be set up in this manner:

Click to expand

We are showing two profiles, one with and without MFA details.

.aws/config

text
[profile test-basic-session]
region = us-east-1

[profile test-basic-session-mfa]
region = us-east-1
mfa_serial = arn:aws:iam::123456789:mfa/testmfa

.aws/credentials

text
[test-basic-session]
aws_access_key_id = XXX
aws_secret_access_key = YYY

[test-basic-session-mfa]
aws_access_key_id = XXX
aws_secret_access_key = YYY

Script

The pseudocode is as follows:

  1. Prompt the user for the source profile name, store it in profileSource.name
  2. Get the region and mfa_serial from the .aws config
  3. If the mfa_serial exists, prompt the user for the MFA code
  4. Create the STS client and make the request to get the temporary credentials
  5. Export the temporary credentials to a new profile named {profileSource.name}-temporary

Available on GitHub: iam-user.js

js
#!/usr/bin/env node
const aws = require('aws-sdk');
const os = require('os');
const inquirer = require('inquirer');
const shell = require('shelljs');

async function main()
{
  /* === Get Inputs from User amd the existing AWS profile === */
  const profilePrompt = await inquirer.prompt([{
    type: 'input',
    name: 'sourceProfile',
    message: 'Source Profile',
  }]);

  const sourceProfileName = profilePrompt.sourceProfile;
  const mfaArn = shell.exec("aws configure get mfa_serial --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const region = shell.exec("aws configure get region --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');

  /* Only ask for an MFA code if we have `mfa_serial` set on the profile  */
  let mfaCode = undefined;
  if(mfaArn)
  {
    const mfaCodePrompt = await inquirer.prompt([{
      type: 'input',
      name: 'mfaCode',
      message: 'MFA Code',
    }]);
    mfaCode = mfaCodePrompt.mfaCode;
  }

  const profileSource = {
    name: sourceProfileName,
    region: region,
    mfaArn: !!mfaArn ? mfaArn : undefined,
    mfaCode: mfaCode
  };
  const profileTarget = {
    name: profileSource.name+'-temporary',
  };

  /* === Call AWS STS to get temporary credentials === */
  let awsCredentials = new aws.SharedIniFileCredentials({profile: profileSource.name});
  let sts = new aws.STS({
    credentials: awsCredentials,
    endpoint: "https://sts."+profileSource.region+".amazonaws.com",
    region: profileSource.region
  });

  let stsMfaSessions = await sts.getSessionToken({
    SerialNumber: profileSource.mfaArn,
    TokenCode: profileSource.mfaCode
  }).promise();

  /* === Export temporary credentials back to AWS profile === */
  shell.exec(`aws configure set region ${profileSource.region}  --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_access_key_id ${stsMfaSessions.Credentials.AccessKeyId}  --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_secret_access_key ${stsMfaSessions.Credentials.SecretAccessKey}  --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_session_token ${stsMfaSessions.Credentials.SessionToken}  --profile `+profileTarget.name);

  console.log("Successfully set AWS Profile: "+profileTarget.name);
  console.log("Expires at: "+stsMfaSessions.Credentials.Expiration);
}
main().catch(err => console.error(err));

Running the script outputs the following:

bash
> node aws/authentication/iam-user.js
? Source Profile test-basic-session
Successfully set AWS Profile: test-basic-session-temporary
Expires at: Fri Nov 18 2022 17:29:45 GMT+0200 (South Africa Standard Time)

The MFA equivalent:

bash
> node aws/authentication/iam-user.js
? Source Profile test-basic-session-mfa
? MFA Code 123456
Successfully set AWS Profile: test-basic-session-mfa-temporary
Expires at: Fri Nov 18 2022 17:29:45 GMT+0200 (South Africa Standard Time)

Then to use the temporary credential with the AWS CLI, specify the --profile argument:

bash
aws s3 ls --profile test-basic-session-temporary
aws s3 ls --profile test-basic-session-mfa-temporary

AWS IAM Role

Here the AWS CLI does prompt for your MFA code if it detects the mfa_serial 🤷. But you get the error below if you enforce the MultiFactorAuthPresent flag to be set in IAM policies.

An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

Example policy that only allows you to list buckets only if you have the MultiFactorAuthPresent flag set to true.

json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:ListBucket"
    ],
    "Resource": ["*"],
    "Condition": {"Bool": {"aws:MultiFactorAuthPresent": "true"}}
  }]
}

It turns out that when the CLI assumes the role, it does not/can not place MultiFactorAuthPresent flag. You have to first call STS with the MFA details like we did for the iam-user.js script and then use those temporary credentials to assume the role.

This is also made clear if your really deep dive into IAM and is listed on the AWS documentation:

The temporary credentials returned by AssumeRole do not include MFA information in the context, so you cannot check individual API operations for MFA. This is why you must use GetSessionToken to restrict access to resources protected by resource-based policies.

This is why we rather play it safe and make the assumption that if you are requiring MFA, you have set up your IAM policies correctly with the MultiFactorAuthPresent condition. It is very confusing why this isn't the default for MFA assumed roles in the AWS CLI.

The next we need to talk about is the duration of the token. When you just assume the role, the maximum duration can be 12 hours. But this is not the case for when you assume the role with the temporary credentials you obtained from STS that have been Multi Factor Authenticated. Then the maximum duration is now 1 hour because now we are role chaining.

We can read about role chaining in the AWS docs here

However, if you assume a role using role chaining and provide a DurationSeconds parameter value greater than one hour, the operation fails.

This is the error that you will get if you specify a duration longer than 1 hour even though the role has a longer maximum duration:

Error [ValidationError]: The requested DurationSeconds exceeds the 1 hour session limit for roles assumed by role chaining.

This is why the scrips below leave the Duration field as default so that your token will be valid for 1 hour.

AWS config and credential file

Your .aws/config and .aws/credentials need to be set up in this manner:

Click to expand

.aws/config

text
[profile test-source]
region = us-east-1

[profile test-assume-role-with-external-id-and-mfa]
region = us-east-1
source_profile = test-source
role_arn = arn:aws:iam::123456789:role/AdminAssumeRoleWithExternalID
external_id = 12345
mfa_serial = arn:aws:iam::123456789:mfa/testmfa

.aws/credentials

text
[test-source]
aws_access_key_id = XXX
aws_secret_access_key = YYY

Script

The pseudocode is as follows:

  1. Prompt the user for the source profile name, store it in profileSource.name
  2. Get the region, mfa_serial, external_id, role_arn and source_profile from the .aws config
  3. If the mfa_serial exists, prompt the user for the MFA code
    1. Use the source_profile, pass the MFA fields and get a temporary STS credential
    2. Else just read the source_profile credential
  4. Create the STS client from the above credential and make the request to assume the role, pass external_id if present
  5. Export the temporary credentials to a new profile named {profileSource.name}-temporary

Available on GitHub: iam-role.js

javascript
#!/usr/bin/env node
const aws = require('aws-sdk');
const os = require('os');
const inquirer = require('inquirer');
const shell = require('shelljs');

async function main()
{
  /* === Get Inputs from User amd the existing AWS profile === */
  const profilePrompt = await inquirer.prompt([{
    type: 'input',
    name: 'sourceProfile',
    message: 'Source Profile',
  }]);

  const sourceProfileName = profilePrompt.sourceProfile;
  const mfaArn = shell.exec("aws configure get mfa_serial --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const region = shell.exec("aws configure get region --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const externalId = shell.exec("aws configure get external_id --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const assumeRoleArn = shell.exec("aws configure get role_arn --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const assumeRoleFromProfile = shell.exec("aws configure get source_profile --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');

  /* Only ask for an MFA code if we have `mfa_serial` set on the profile  */
  let mfaCode = undefined;
  if(mfaArn)
  {
    const mfaCodePrompt = await inquirer.prompt([{
      type: 'input',
      name: 'mfaCode',
      message: 'MFA Code',
    }]);
    mfaCode = mfaCodePrompt.mfaCode;
  }

  const profileSource = {
    name: sourceProfileName,
    region: region,
    mfaArn: !!mfaArn ? mfaArn : undefined,
    mfaCode: mfaCode,
    externalId: !!externalId ? externalId : undefined,
    assumeRoleArn: assumeRoleArn,
    assumeRoleFromProfile: assumeRoleFromProfile,
  };
  const profileTarget = {
    name: profileSource.name+'-temporary',
    roleSessionName: profileSource.name + "." + profileSource.assumeRoleArn.split('/').slice(-1) // Can be anything descriptive
  };

  /* If MFA is required, we need to first get a temporary session that has the MFA flag set, then assume role */
  let awsAssumeRoleCred;
  if(!profileSource.mfaArn)
    awsAssumeRoleCred = new aws.SharedIniFileCredentials({profile: profileSource.assumeRoleFromProfile});
  else
  {
    const awsCredentials = new aws.SharedIniFileCredentials({profile: profileSource.assumeRoleFromProfile});
    const sts = new aws.STS({
      credentials: awsCredentials,
      endpoint: "https://sts."+profileSource.region+".amazonaws.com",
      region: profileSource.region
    });

    const tokenDuration = 43200;
    const stsMfaSessions = await sts.getSessionToken({
      DurationSeconds: tokenDuration,
      SerialNumber: profileSource.mfaArn,
      TokenCode: profileSource.mfaCode
    }).promise();

    awsAssumeRoleCred = new aws.Credentials({
      accessKeyId: stsMfaSessions.Credentials.AccessKeyId,
      secretAccessKey: stsMfaSessions.Credentials.SecretAccessKey,
      sessionToken: stsMfaSessions.Credentials.SessionToken,
    });
  }

  const stsSession = new aws.STS({
    credentials: awsAssumeRoleCred,
    endpoint: "https://sts."+profileSource.region+".amazonaws.com",
    region: profileSource.region
  });
  const stsAssumeRoleCredentials = await stsSession.assumeRole({
    /* Defaults to 1 hour (3600 seconds), can be more depending on role maximum but not more than 1 hour if role chaining with MFA */
    // DurationSeconds: 43200,
    SerialNumber: profileSource.mfaArn,
    TokenCode: profileSource.mfaCode,
    RoleSessionName: profileTarget.roleSessionName,
    RoleArn: profileSource.assumeRoleArn,
    ExternalId: profileSource.externalId
  }).promise();

  shell.exec(`aws configure set region ${profileSource.region} --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_access_key_id ${stsAssumeRoleCredentials.Credentials.AccessKeyId} --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_secret_access_key ${stsAssumeRoleCredentials.Credentials.SecretAccessKey} --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_session_token ${stsAssumeRoleCredentials.Credentials.SessionToken} --profile `+profileTarget.name);

  console.log("Successfully set AWS Profile: "+profileTarget.name);
  console.log("Expires at: "+stsAssumeRoleCredentials.Credentials.Expiration);
}
main().catch(err => console.error(err));

AWS SSO

The AWS CLI support for SSO is pretty decent. You start off with aws sso login --profile test-sso-dev which then opens the web interface where you enter your username, password and MFA code. Once done you can just use the AWS CLI as per normal with the --profile argument.

Click to expand pictures of the web flow

AWS config and credential file

Your .aws/config and .aws/credentials need to be set up in this manner:

Click to expand

.aws/config

text
[profile test-sso-dev]
sso_start_url = https://d-123456789.awsapps.com/start
sso_region = us-east-1
sso_account_id = 123456789
sso_role_name = AWSAdministratorAccess
region = eu-west-1

.aws/credentials

text

Nothing in the credentials file

Script

The pseudocode is as follows:

  1. Prompt the user for the source profile name, store it in profileSource.name
  2. Get the region, sso_start_url, sso_region, sso_account_id and sso_role_name from the .aws config
  3. Create an instance of the SSO helper class that is just a wrapper for these two libraries: @aws-sdk/client-sso and @aws-sdk/client-sso-oidc.
  4. Create an SSO client and then a device, this user code needs to be entered into the first SSO screen, but we can also fill it in automatically by appending it to the SSO url(?user_code=${userCode})
  5. Open the generated SSO url in the browser and let the user fill in the username, password and MFA code. This works exactly the same as when the AWS CLI does SSO(see screenshots above).
  6. Once the user sees the "Request Approved" screen, they can close the window/tab and press enter on the command line to continue the script.
  7. The next API call made is to check if the user logged into SSO correctly, this returns an SSO Access Token.
  8. Then we use the SSO Access Token to get temporary AWS credentials.
  9. Export the temporary credentials to a new profile named {profileSource.name}-temporary

Available on GitHub: sso.js

javascript
#!/usr/bin/env node
const { SSOClient, ListAccountsCommand, ListAccountRolesCommand, GetRoleCredentialsCommand  } = require("@aws-sdk/client-sso");
const { SSOOIDCClient, RegisterClientCommand, StartDeviceAuthorizationCommand, CreateTokenCommand, AuthorizationPendingException } = require("@aws-sdk/client-sso-oidc");
const os = require('os');
const inquirer = require('inquirer');
const shell = require('shelljs');
const open = require('open');

class SSOHelper
{
  startUrl;
  region;
  clientName;
  clientSso;
  clientDevice;

  constructor(startUrl, region, clientName) {
    this.startUrl = startUrl;
    this.region = region;
    this.clientName = clientName;
    this.clientSso = new SSOClient({ region: region });
    this.clientDevice = new SSOOIDCClient({ region: region });
  }

  registerClient = async () => {
    const registerClientCommand = new RegisterClientCommand({clientName: this.clientName, clientType: 'public'})
    return await this.clientDevice.send(registerClientCommand)
  }

  authorizeDevice = async (clientId, clientSecret) => {
    const startDeviceAuthorizationCommand = new StartDeviceAuthorizationCommand({
      clientId: clientId, clientSecret: clientSecret, startUrl: this.startUrl
    })
    const {verificationUri, deviceCode, userCode} = await this.clientDevice.send(startDeviceAuthorizationCommand);

    return {
      verificationUri,
      deviceCode,
      userCode
    };
  }

  getAccessToken = async (clientId, clientSecret, deviceCode, userCode) => {
    const createTokenCommand = new CreateTokenCommand({
      clientId: clientId,
      clientSecret: clientSecret,
      grantType: 'urn:ietf:params:oauth:grant-type:device_code',
      deviceCode: deviceCode,
      code: userCode
    })
    return await this.clientDevice.send(createTokenCommand);
  }

  getAccounts = async (accessToken) => {
    const listAccountsCommand = new ListAccountsCommand({accessToken: accessToken});
    return await this.clientSso.send(listAccountsCommand);
  }

  getAccountRoles = async (accessToken, accountId) => {
    const listAccountRolesCommand = new ListAccountRolesCommand({accessToken: accessToken, accountId: accountId});
    return await this.clientSso.send(listAccountRolesCommand);
  }

  getAccountRoleCredentials = async (accessToken, accountId, roleName) => {
    const getRoleCredentialsCommand = new GetRoleCredentialsCommand({
      accessToken: accessToken, accountId: accountId, roleName: roleName
    });
    const {roleCredentials} = await this.clientSso.send(getRoleCredentialsCommand);
    return roleCredentials;
  }
}

async function main()
{
  /* === Get Inputs from User amd the existing AWS profile === */
  const profilePrompt = await inquirer.prompt([{
    type: 'input',
    name: 'sourceProfile',
    message: 'Source Profile',
  }]);

  const sourceProfileName = profilePrompt.sourceProfile;
  const ssoStartUrl = shell.exec("aws configure get sso_start_url --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const ssoRegion = shell.exec("aws configure get sso_region --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const ssoAccountId = shell.exec("aws configure get sso_account_id --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const ssoRoleName = shell.exec("aws configure get sso_role_name --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');
  const region = shell.exec("aws configure get region --profile "+sourceProfileName, {silent: true}).stdout.replaceAll(os.EOL, '');

  const profileSource = {
    name: sourceProfileName,
    ssoStartUrl: ssoStartUrl,
    ssoRegion: ssoRegion,
    ssoAccountId: ssoAccountId,
    ssoRoleName: ssoRoleName,
    region: region,
  };
  const profileTarget = {
    name: profileSource.name+'-temporary',
    ssoSessionName: profileSource.name + "." + ssoRoleName // Can be anything descriptive
  };

  const ssoHelper = new SSOHelper(profileSource.ssoStartUrl, profileSource.ssoRegion, profileTarget.ssoSessionName)
  const {clientId, clientSecret} = await ssoHelper.registerClient();
  if(!clientId || !clientSecret)
    throw new Error("Can not register SSO Client");

  const {deviceCode, userCode, verificationUri} = await ssoHelper.authorizeDevice(clientId, clientSecret);
  if(!deviceCode || !userCode || !verificationUri)
    throw new Error("Can not register SSO Authorized Device");

  const requestUrl = `${verificationUri}?user_code=${userCode}`;
  await open(requestUrl);

  await inquirer.prompt([{
    type: 'input',
    name: 'done',
    message: 'Press enter if you completed the SSO Login and see the "Request Approved" screen.',
  }]);

  let resp = await ssoHelper.getAccessToken(clientId, clientSecret, deviceCode, userCode);
  if(!resp.accessToken || !resp.expiresIn)
    throw new Error("Can not get SSO Access Token");

  const creds = await ssoHelper.getAccountRoleCredentials(resp.accessToken, ssoAccountId, ssoRoleName)
  if(!creds)
    throw new Error("Could not get AWS tokens using the SSO access token");

  /* === Export temporary credentials back to AWS profile === */
  shell.exec(`aws configure set region ${profileSource.region}  --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_access_key_id ${creds.accessKeyId}  --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_secret_access_key ${creds.secretAccessKey}  --profile `+profileTarget.name);
  shell.exec(`aws configure set aws_session_token ${creds.sessionToken}  --profile `+profileTarget.name);

  console.log("Successfully set AWS Profile: "+profileTarget.name);
  console.log("Expires at: "+ (new Date((creds.expiration))));
}
main().catch(err => console.error(err));

Conclusion

The AWS CLI has some unintuitive workflow issues when it comes to IAM Users and Roles especially around MFA.

For IAM Users:

  • Does not exchange long-lived keys for temporary credentials using STS before making other AWS CLI calls.
  • Does not read mfa_srial and prompt for the MFA code.

For IAM Roles:

  • When mfa_srial is set, it does not first call STS to get a multi-factor authenticated session before assuming the role, so all IAM policies that check for the MultiFactorAuthPresent condition will fail.

AWS SSO is pretty awesome because you don't need to store any long-lived credentials on disk. It takes a bit longer to log in with the AWS CLI and the two commands could have been one, but well worth the effort.

These scripts do not handle caching and the AWS IAM User and Role solutions still store long-lived credentials on disk in plain text. You can also extend the duration longer than 1 hour in certain scenarios. This is where Cloud Glance makes your life easier:

  • ✅ Don't worry about strange MFA caveats (explained in this article)
  • ✅ Open multiple AWS consoles at the same time with Firefox Containers
  • ✅ Works alongside the AWS CLI and your existing .aws/credentials and .aws/config
  • ✅ Securely stores long-lived IAM credentials on disk, this encryption is opt-in
  • ✅ Deep bookmark links directly to service resources, ex: Prod CloudWatch Dashboard

There are many more features of CloudGlance including managing Bastion port forwarding and also Tracked Security Groups that sync your computer IP with the rules in an AWS Security Group. Check it out here: https://cloudglance.dev/