How to fix the profile support in the AWS JS SDK v3

Error: Profile [profile] requires a role to be assumed, but no role assumption callback was provided.

Author's image
Tamás Sallai
5 mins

Migrating to v3

Two months ago, AWS released the v3 of the Javascript SDK. It brings quite a few updates and unfortunately it needs a different structure than the v2, so a migration is in order for all applications that use that. But since it's the new version and it's likely that in the future this will be the mainstream way of writing Javascript code interfacing with the AWS APIs, I started using it for new projects.

As usual, I started with a "Hello world"-style program, just to have a working baseline. This is adapted from the documentation:

import {DynamoDBClient, ListTablesCommand} from "@aws-sdk/client-dynamodb";

(async () => {
	const client = new DynamoDBClient({});
	const command = new ListTablesCommand({});
	try {
		const results = await client.send(command);
		console.log(results.TableNames.join("\n"));
	} catch (err) {
		console.error(err);
	}
})();

Well, it did not go well:

Error: Profile sandbox requires a role to be assumed, but no role assumption callback was provided.

It turned out that multiple tickets are opened already for this issue, some dating back to May.

Upon further investigation, it is noted in the release blog post (emphasis mine):

What’s missing in v3

The following features are not available in version 3 yet, we are working to add support for them::

  • SDK Metrics publishing
  • Credential Providers
    • Web identity credential provider
    • Token file web identity credential provider
    • Chainable temporary credential provider
    • Automatic assuming the role from role_arn in credential file

Well, that's a rough start. But let's see what can be done with it!

What are profiles

The root of the problem is the incomplete handling of profiles by the SDK.

Profiles are an easy way to use roles with the AWS CLI and the SDKs. When you configure credentials for the CLI, you use long-term credentials, which are added to an IAM user. This is a pair of Access Key ID and Secret Access Key and these identify the user when you make calls to the AWS APIs.

For example, this is a set of credentials configured for the CLI:

[user1]
aws_access_key_id=AKIAIOSFODNN7EXAMPLE
aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

This is enough to get started coding for AWS on your computer but falls short for more complicated scenarios. IAM Roles are a way to issue a set of temporary credentials. They are used for almost everything else other than giving people access to an AWS account.

A profile defines a role and a source profile. The source provides the necessary keys to assume the role. For example, this configuration defines a profile that uses the above credentials to use a specific role when needed:

[profile marketingadmin]
role_arn = arn:aws:iam::123456789012:role/marketingadminrole
source_profile = user1

With the above configuration, you can run commands using the role with a simple parameter to the CLI:

aws s3 ls --profile marketingadmin

Under the hood, it uses the source_profile's credentials to issue an sts:AssumeRole action to get the role's credentials, then uses them to send a request to the S3 service. It automatically caches and refreshes the role's keys when needed, providing an efficient workflow.

Before the new SDK, it worked the same in code too. Using the AWS_PROFILE environment variable, you could use the same mechanism to use roles:

AWS_PROFILE=marketingadmin node index.js

Unfortunately, the v3 Javascript SDK does not support this construct, making the code depend on how the credentials are specified.

Fixing profiles in the v3 SDK

The first ticket provides the base of a solution though. The problem is this line in the credential provider:

if (!options.roleAssumer) {
	throw new ProviderError(
		`Profile ${profileName} requires a role to be assumed, but no` + ` role assumption callback was provided.`,
		false
	);
}

It requires a function that implements how to assume a role. This is called from the credential-provider-node, but the roleAssumer comes as undefined.

Why?

It turns out it's a side effect of the modularization effort. Assuming a role requires a call to the STS service. And since all the other services require credentials, all of them would depend on the STS code, which goes against the notion of only loading what is used. Because of this, the services have no way to assume a role, that's why by default the credentials provider does not pass a roleAssumer function.

After this investigation, the solution is straightforward. Construct a credentials provides that uses the STS service to assume a role when needed.

import {DynamoDBClient, ListTablesCommand} from "@aws-sdk/client-dynamodb";
import {Credentials} from "@aws-sdk/types";
import {defaultProvider} from "@aws-sdk/credential-provider-node";
import {AssumeRoleParams} from "@aws-sdk/credential-provider-ini";
import {STS} from "@aws-sdk/client-sts";

// assume a role using the sourceCreds
async function assume(sourceCreds: Credentials, params: AssumeRoleParams): Promise<Credentials> {
	const sts = new STS({credentials: sourceCreds});
	const result = await sts.assumeRole(params);
	if(!result.Credentials) {
		throw new Error("unable to assume credentials - empty credential object");
	}
	return {
		accessKeyId: String(result.Credentials.AccessKeyId),
		secretAccessKey: String(result.Credentials.SecretAccessKey),
		sessionToken: result.Credentials.SessionToken
	}
}

(async () => {
	// use the provider that can assume roles for the client
	const client = new DynamoDBClient({credentials: defaultProvider({roleAssumer: assume})});
	const command = new ListTablesCommand({});
	try {
		const results = await client.send(command);
		console.log(results.TableNames.join("\n"));
	} catch (err) {
		console.error(err);
	}
})();

It's quite a lot of extra code, and the provider has to be passed to all the client services to add reliable support for profiles. It also increases the number of dependencies, often in a way that is not needed for the production code. For example, you might use profiles to test a Lambda function locally, but when deployed, the Lambda service assumes the role on behalf of the function. This makes the included STS code an extra and unneeded dependency.

February 23, 2021
In this article