Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(credential-provider-ini): fix recursive assume role and optional role_arn in credential_source #6472

Merged
merged 3 commits into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,15 @@ describe(resolveAssumeRoleCredentials.name, () => {

const receivedCreds = await resolveAssumeRoleCredentials(mockProfileCurrent, mockProfilesWithSource, mockOptions);
expect(receivedCreds).toStrictEqual(mockCreds);
expect(resolveProfileData).toHaveBeenCalledWith(mockProfileName, mockProfilesWithSource, mockOptions, {
mockProfileName: true,
});
expect(resolveProfileData).toHaveBeenCalledWith(
mockProfileName,
mockProfilesWithSource,
mockOptions,
{
mockProfileName: true,
},
false
);
expect(resolveCredentialSource).not.toHaveBeenCalled();
expect(mockOptions.roleAssumer).toHaveBeenCalledWith(mockSourceCredsFromProfile, {
RoleArn: mockRoleAssumeParams.role_arn,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CredentialsProviderError } from "@smithy/property-provider";
import { getProfileName } from "@smithy/shared-ini-file-loader";
import { AwsCredentialIdentity, Logger, ParsedIniData, Profile } from "@smithy/types";
import { AwsCredentialIdentity, IniSection, Logger, ParsedIniData, Profile } from "@smithy/types";

import { FromIniInit } from "./fromIni";
import { resolveCredentialSource } from "./resolveCredentialSource";
Expand Down Expand Up @@ -140,43 +140,62 @@ export const resolveAssumeRoleCredentials = async (
const sourceCredsProvider: Promise<AwsCredentialIdentity> = source_profile
? resolveProfileData(
source_profile,
{
...profiles,
[source_profile]: {
...profiles[source_profile],
// This assigns the role_arn of the "root" profile
// to the credential_source profile so this recursive call knows
// what role to assume.
role_arn: data.role_arn ?? profiles[source_profile].role_arn,
},
},
profiles,
options,
{
...visitedProfiles,
[source_profile]: true,
}
},
isCredentialSourceWithoutRoleArn(profiles[source_profile!] ?? {})
)
: (await resolveCredentialSource(data.credential_source!, profileName, options.logger)(options))();

const params: AssumeRoleParams = {
RoleArn: data.role_arn!,
RoleSessionName: data.role_session_name || `aws-sdk-js-${Date.now()}`,
ExternalId: data.external_id,
DurationSeconds: parseInt(data.duration_seconds || "3600", 10),
};

const { mfa_serial } = data;
if (mfa_serial) {
if (!options.mfaCodeProvider) {
throw new CredentialsProviderError(
`Profile ${profileName} requires multi-factor authentication, but no MFA code callback was provided.`,
{ logger: options.logger, tryNextLink: false }
);
if (isCredentialSourceWithoutRoleArn(data)) {
/**
* This control-flow branch is accessed when in a chained source_profile
* scenario, and the last step of the chain is a credential_source
* without its own role_arn. In this case, we return the credentials
* of the credential_source so that the previous recursive layer
* can use its role_arn instead of redundantly needing another role_arn at
* this final layer.
*/
return sourceCredsProvider;
} else {
const params: AssumeRoleParams = {
RoleArn: data.role_arn!,
RoleSessionName: data.role_session_name || `aws-sdk-js-${Date.now()}`,
ExternalId: data.external_id,
DurationSeconds: parseInt(data.duration_seconds || "3600", 10),
};

const { mfa_serial } = data;
if (mfa_serial) {
if (!options.mfaCodeProvider) {
throw new CredentialsProviderError(
`Profile ${profileName} requires multi-factor authentication, but no MFA code callback was provided.`,
{ logger: options.logger, tryNextLink: false }
);
}
params.SerialNumber = mfa_serial;
params.TokenCode = await options.mfaCodeProvider(mfa_serial);
}
params.SerialNumber = mfa_serial;
params.TokenCode = await options.mfaCodeProvider(mfa_serial);

const sourceCreds = await sourceCredsProvider;
return options.roleAssumer!(sourceCreds, params);
}
};

const sourceCreds = await sourceCredsProvider;
return options.roleAssumer!(sourceCreds, params);
/**
* @internal
*
* Returns true when the ini section in question, typically a profile,
* has a credential_source but not a role_arn.
*
* Previously, a role_arn was a required sibling element to credential_source.
* However, this would require a role_arn+source_profile pointed to a
* credential_source to have a second role_arn, resulting in at least two
* calls to assume-role.
*/
const isCredentialSourceWithoutRoleArn = (section: IniSection): boolean => {
return !section.role_arn && !!section.credential_source;
};
12 changes: 10 additions & 2 deletions packages/credential-provider-ini/src/resolveProfileData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ export const resolveProfileData = async (
profileName: string,
profiles: ParsedIniData,
options: FromIniInit,
visitedProfiles: Record<string, true> = {}
visitedProfiles: Record<string, true> = {},
/**
* This override comes from recursive calls only.
* It is used to flag a recursive profile section
* that does not have a role_arn, e.g. a credential_source
* with no role_arn, as part of a larger recursive assume-role
* call stack, and to re-enter the assume-role resolver function.
*/
isAssumeRoleRecursiveCall = false
): Promise<AwsCredentialIdentity> => {
const data = profiles[profileName];

Expand All @@ -28,7 +36,7 @@ export const resolveProfileData = async (

// If this is the first profile visited, role assumption keys should be
// given precedence over static credentials.
if (isAssumeRoleProfile(data, { profile: profileName, logger: options.logger })) {
if (isAssumeRoleRecursiveCall || isAssumeRoleProfile(data, { profile: profileName, logger: options.logger })) {
return resolveAssumeRoleCredentials(profileName, profiles, options, visitedProfiles);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jest.mock("@aws-sdk/client-sso", () => {
// This var must be hoisted.
// eslint-disable-next-line no-var
var stsSpy: jest.Spied<any> | any | undefined = undefined;
const assumeRoleArns: string[] = [];

jest.mock("@aws-sdk/client-sts", () => {
const actual = jest.requireActual("@aws-sdk/client-sts");
Expand All @@ -80,6 +81,7 @@ jest.mock("@aws-sdk/client-sts", () => {

stsSpy = jest.spyOn(actual.STSClient.prototype, "send").mockImplementation(async function (this: any, command: any) {
if (command.constructor.name === "AssumeRoleCommand") {
assumeRoleArns.push(command.input.RoleArn);
return {
Credentials: {
AccessKeyId: "STS_AR_ACCESS_KEY_ID",
Expand All @@ -91,6 +93,7 @@ jest.mock("@aws-sdk/client-sts", () => {
};
}
if (command.constructor.name === "AssumeRoleWithWebIdentityCommand") {
assumeRoleArns.push(command.input.RoleArn);
return {
Credentials: {
AccessKeyId: "STS_ARWI_ACCESS_KEY_ID",
Expand Down Expand Up @@ -177,6 +180,22 @@ describe("credential-provider-node integration test", () => {
let sts: STS = null as any;
let processSnapshot: typeof process.env = null as any;

const sink = {
data: [] as string[],
debug(log: string) {
this.data.push(log);
},
info(log: string) {
this.data.push(log);
},
warn(log: string) {
this.data.push(log);
},
error(log: string) {
this.data.push(log);
},
};

const RESERVED_ENVIRONMENT_VARIABLES = {
AWS_DEFAULT_REGION: 1,
AWS_REGION: 1,
Expand Down Expand Up @@ -257,6 +276,8 @@ describe("credential-provider-node integration test", () => {
output: "json",
},
};
assumeRoleArns.length = 0;
sink.data.length = 0;
});

afterAll(async () => {
Expand Down Expand Up @@ -511,7 +532,7 @@ describe("credential-provider-node integration test", () => {
});
});

it("should be able to combine a source_profile having credential_source with an origin profile having role_arn and source_profile", async () => {
it("should be able to combine a source_profile having only credential_source with an origin profile having role_arn and source_profile", async () => {
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23";
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization";
iniProfileData.default.source_profile = "credential_source_profile";
Expand All @@ -529,6 +550,138 @@ describe("credential-provider-node integration test", () => {
clientConfig: {
region: "us-west-2",
},
logger: sink,
}),
});
await sts.getCallerIdentity({});
const credentials = await sts.config.credentials();
expect(credentials).toEqual({
accessKeyId: "STS_AR_ACCESS_KEY_ID",
secretAccessKey: "STS_AR_SECRET_ACCESS_KEY",
sessionToken: "STS_AR_SESSION_TOKEN",
expiration: new Date("3000-01-01T00:00:00.000Z"),
credentialScope: "us-stsar-1__us-west-2",
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
})
);
expect(assumeRoleArns).toEqual(["ROLE_ARN"]);
spy.mockClear();
});

it("should be able to combine a source_profile having web_identity_token_file and role_arn with an origin profile having role_arn and source_profile", async () => {
iniProfileData.default.source_profile = "credential_source_profile";
iniProfileData.default.role_arn = "ROLE_ARN_2";

iniProfileData.credential_source_profile = {
web_identity_token_file: "token-filepath",
role_arn: "ROLE_ARN_1",
};

sts = new STS({
region: "us-west-2",
requestHandler: mockRequestHandler,
credentials: defaultProvider({
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
clientConfig: {
region: "us-west-2",
},
logger: sink,
}),
});
await sts.getCallerIdentity({});
const credentials = await sts.config.credentials();
expect(credentials).toEqual({
accessKeyId: "STS_AR_ACCESS_KEY_ID",
secretAccessKey: "STS_AR_SECRET_ACCESS_KEY",
sessionToken: "STS_AR_SESSION_TOKEN",
expiration: new Date("3000-01-01T00:00:00.000Z"),
credentialScope: "us-stsar-1__us-west-2",
});
expect(assumeRoleArns).toEqual(["ROLE_ARN_1", "ROLE_ARN_2"]);
});

it("should complete chained role_arn credentials", async () => {
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23";
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization";

iniProfileData.default.source_profile = "credential_source_profile_1";
iniProfileData.default.role_arn = "ROLE_ARN_3";

iniProfileData.credential_source_profile_1 = {
source_profile: "credential_source_profile_2",
role_arn: "ROLE_ARN_2",
};

iniProfileData.credential_source_profile_2 = {
credential_source: "EcsContainer",
role_arn: "ROLE_ARN_1",
};

const spy = jest.spyOn(credentialProviderHttp, "fromHttp");
sts = new STS({
region: "us-west-2",
requestHandler: mockRequestHandler,
credentials: defaultProvider({
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
clientConfig: {
region: "us-west-2",
},
logger: sink,
}),
});
await sts.getCallerIdentity({});
const credentials = await sts.config.credentials();
expect(credentials).toEqual({
accessKeyId: "STS_AR_ACCESS_KEY_ID",
secretAccessKey: "STS_AR_SECRET_ACCESS_KEY",
sessionToken: "STS_AR_SESSION_TOKEN",
expiration: new Date("3000-01-01T00:00:00.000Z"),
credentialScope: "us-stsar-1__us-west-2",
});
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
})
);
expect(assumeRoleArns).toEqual(["ROLE_ARN_1", "ROLE_ARN_2", "ROLE_ARN_3"]);
spy.mockClear();
});

it("should complete chained role_arn credentials with optional role_arn in credential_source step", async () => {
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = "http://169.254.170.23";
process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN = "container-authorization";

iniProfileData.default.source_profile = "credential_source_profile_1";
iniProfileData.default.role_arn = "ROLE_ARN_3";

iniProfileData.credential_source_profile_1 = {
source_profile: "credential_source_profile_2",
role_arn: "ROLE_ARN_2",
};

iniProfileData.credential_source_profile_2 = {
credential_source: "EcsContainer",
// This scenario tests the option of having no role_arn in this step of the chain.
};

const spy = jest.spyOn(credentialProviderHttp, "fromHttp");
sts = new STS({
region: "us-west-2",
requestHandler: mockRequestHandler,
credentials: defaultProvider({
awsContainerCredentialsFullUri: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
clientConfig: {
region: "us-west-2",
},
logger: sink,
}),
});
await sts.getCallerIdentity({});
Expand All @@ -546,6 +699,7 @@ describe("credential-provider-node integration test", () => {
awsContainerAuthorizationToken: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN,
})
);
expect(assumeRoleArns).toEqual(["ROLE_ARN_2", "ROLE_ARN_3"]);
spy.mockClear();
});
});
Expand Down
Loading