Skip to content

Commit 9480e70

Browse files
authored
feat(credential-provider-sso): support sso credential when resolving shared credential file (#2583)
* feat(util-credentials): move shared credential utils to util-credential package * fix(credential-provider-sso): support sso credential in ini credential provider
1 parent 160aeba commit 9480e70

File tree

19 files changed

+829
-219
lines changed

19 files changed

+829
-219
lines changed

Diff for: packages/credential-provider-ini/README.md

+42-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ aws_access_key_id=foo
9090
aws_secret_access_key=bar
9191

9292
[first]
93-
source_profile=first
93+
source_profile=second
9494
role_arn=arn:aws:iam::123456789012:role/example-role-arn
9595
```
9696

@@ -125,3 +125,44 @@ credential_source = EcsContainer
125125
web_identity_token_file=/temp/token
126126
role_arn=arn:aws:iam::123456789012:role/example-role-arn
127127
```
128+
129+
You can specify another profile(`second`) whose credentials are used to assume
130+
the role by the `role_arn` setting in this profile(`first`).
131+
132+
```ini
133+
[second]
134+
web_identity_token_file=/temp/token
135+
role_arn=arn:aws:iam::123456789012:role/example-role-2
136+
137+
[first]
138+
source_profile=second
139+
role_arn=arn:aws:iam::123456789012:role/example-role
140+
```
141+
142+
### profile with sso credentials
143+
144+
Please refer the the [`sso credential provider package`](https://www.npmjs.com/package/@aws-sdk/credential-provider-sso)
145+
for how to configure the SSO credentials.
146+
147+
```ini
148+
[default]
149+
sso_account_id = 012345678901
150+
sso_region = us-east-1
151+
sso_role_name = SampleRole
152+
sso_start_url = https://d-abc123.awsapps.com/start
153+
```
154+
155+
You can specify another profile(`second`) whose credentials derived from SSO
156+
are used to assume the role by the `role_arn` setting in this profile(`first`).
157+
158+
```ini
159+
[second]
160+
sso_account_id = 012345678901
161+
sso_region = us-east-1
162+
sso_role_name = example-role-2
163+
sso_start_url = https://d-abc123.awsapps.com/start
164+
165+
[first]
166+
source_profile=second
167+
role_arn=arn:aws:iam::123456789012:role/example-role
168+
```

Diff for: packages/credential-provider-ini/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
"dependencies": {
2424
"@aws-sdk/credential-provider-env": "3.20.0",
2525
"@aws-sdk/credential-provider-imds": "3.20.0",
26+
"@aws-sdk/credential-provider-sso": "3.21.0",
2627
"@aws-sdk/credential-provider-web-identity": "3.20.0",
2728
"@aws-sdk/property-provider": "3.20.0",
2829
"@aws-sdk/shared-ini-file-loader": "3.20.0",
2930
"@aws-sdk/types": "3.20.0",
31+
"@aws-sdk/util-credentials": "3.0.0",
3032
"tslib": "^2.0.0"
3133
},
3234
"devDependencies": {

Diff for: packages/credential-provider-ini/src/index.spec.ts

+119-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { fromEnv } from "@aws-sdk/credential-provider-env";
22
import { fromContainerMetadata, fromInstanceMetadata } from "@aws-sdk/credential-provider-imds";
3+
import { fromSSO, isSsoProfile, validateSsoProfile } from "@aws-sdk/credential-provider-sso";
34
import { fromTokenFile } from "@aws-sdk/credential-provider-web-identity";
45
import { ENV_CONFIG_PATH, ENV_CREDENTIALS_PATH } from "@aws-sdk/shared-ini-file-loader";
56
import { Credentials } from "@aws-sdk/types";
7+
import { ENV_PROFILE } from "@aws-sdk/util-credentials";
68
import { join, sep } from "path";
79

8-
import { AssumeRoleParams, ENV_PROFILE, fromIni } from "./";
10+
import { AssumeRoleParams, fromIni } from "./";
911

1012
jest.mock("fs", () => {
1113
interface FsModule {
@@ -60,6 +62,8 @@ jest.mock("@aws-sdk/credential-provider-imds");
6062

6163
jest.mock("@aws-sdk/credential-provider-env");
6264

65+
jest.mock("@aws-sdk/credential-provider-sso");
66+
6367
const DEFAULT_CREDS = {
6468
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
6569
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
@@ -988,6 +992,120 @@ role_arn = ${roleArn}`.trim()
988992
});
989993
});
990994

995+
describe("assume role with SSO", () => {
996+
const DEFAULT_PATH = join(homedir(), ".aws", "credentials");
997+
it("should continue if profile is not configured with an SSO credential", async () => {
998+
__addMatcher(
999+
DEFAULT_PATH,
1000+
`[default]
1001+
aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
1002+
aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
1003+
aws_session_token = ${DEFAULT_CREDS.sessionToken}
1004+
`.trim()
1005+
);
1006+
await fromIni()();
1007+
expect(fromSSO).not.toHaveBeenCalled();
1008+
});
1009+
1010+
it("should throw if profile is configured with incomplete SSO credential", async () => {
1011+
(isSsoProfile as unknown as jest.Mock).mockImplementationOnce(() => true);
1012+
const originalValidator = jest.requireActual("@aws-sdk/credential-provider-sso").validateSsoProfile;
1013+
(validateSsoProfile as unknown as jest.Mock).mockImplementationOnce(originalValidator);
1014+
__addMatcher(
1015+
DEFAULT_PATH,
1016+
`[default]
1017+
sso_account_id = 1234567890
1018+
sso_start_url = https://example.com/sso/
1019+
`.trim()
1020+
);
1021+
try {
1022+
await fromIni()();
1023+
} catch (e) {
1024+
console.error(e.message);
1025+
expect(e.message).toEqual(expect.stringContaining("Profile is configured with invalid SSO credentials"));
1026+
}
1027+
});
1028+
1029+
it("should resolve valid SSO credential", async () => {
1030+
(isSsoProfile as unknown as jest.Mock).mockImplementationOnce(() => true);
1031+
const originalValidator = jest.requireActual("@aws-sdk/credential-provider-sso").validateSsoProfile;
1032+
(validateSsoProfile as jest.Mock).mockImplementationOnce(originalValidator);
1033+
(fromSSO as jest.Mock).mockImplementationOnce(() => async () => DEFAULT_CREDS);
1034+
const accountId = "1234567890";
1035+
const startUrl = "https://example.com/sso/";
1036+
const region = "us-east-1";
1037+
const roleName = "role";
1038+
__addMatcher(
1039+
DEFAULT_PATH,
1040+
`[default]
1041+
sso_account_id = ${accountId}
1042+
sso_start_url = ${startUrl}
1043+
sso_region = ${region}
1044+
sso_role_name = ${roleName}
1045+
`.trim()
1046+
);
1047+
await fromIni()();
1048+
expect(fromSSO as unknown as jest.Mock).toBeCalledWith({
1049+
ssoAccountId: accountId,
1050+
ssoStartUrl: startUrl,
1051+
ssoRegion: region,
1052+
ssoRoleName: roleName,
1053+
});
1054+
});
1055+
1056+
it("should call fromTokenFile with assume role chaining", async () => {
1057+
(isSsoProfile as unknown as jest.Mock).mockImplementationOnce(
1058+
jest.requireActual("@aws-sdk/credential-provider-sso").isSsoProfile
1059+
);
1060+
(validateSsoProfile as unknown as jest.Mock).mockImplementationOnce(
1061+
jest.requireActual("@aws-sdk/credential-provider-sso").validateSsoProfile
1062+
);
1063+
(fromSSO as jest.Mock).mockImplementationOnce(() => async () => DEFAULT_CREDS);
1064+
const accountId = "1234567890";
1065+
const startUrl = "https://example.com/sso/";
1066+
const region = "us-east-1";
1067+
const roleName = "role";
1068+
const roleAssumerWithWebIdentity = jest.fn();
1069+
1070+
const fooRoleArn = "arn:aws:iam::123456789:role/foo";
1071+
const fooSessionName = "fooSession";
1072+
__addMatcher(
1073+
DEFAULT_PATH,
1074+
`
1075+
[bar]
1076+
sso_account_id = ${accountId}
1077+
sso_start_url = ${startUrl}
1078+
sso_region = ${region}
1079+
sso_role_name = ${roleName}
1080+
1081+
[foo]
1082+
role_arn = ${fooRoleArn}
1083+
role_session_name = ${fooSessionName}
1084+
source_profile = bar`.trim()
1085+
);
1086+
1087+
const provider = fromIni({
1088+
profile: "foo",
1089+
roleAssumer(sourceCreds: Credentials, params: AssumeRoleParams): Promise<Credentials> {
1090+
expect(sourceCreds).toEqual(DEFAULT_CREDS);
1091+
expect(params.RoleArn).toEqual(fooRoleArn);
1092+
expect(params.RoleSessionName).toEqual(fooSessionName);
1093+
return Promise.resolve(FOO_CREDS);
1094+
},
1095+
roleAssumerWithWebIdentity,
1096+
});
1097+
1098+
expect(await provider()).toEqual(FOO_CREDS);
1099+
expect(fromSSO).toHaveBeenCalledTimes(1);
1100+
expect(fromSSO).toHaveBeenCalledWith({
1101+
ssoAccountId: accountId,
1102+
ssoStartUrl: startUrl,
1103+
ssoRegion: region,
1104+
ssoRoleName: roleName,
1105+
});
1106+
});
1107+
});
1108+
9911109
it("should prefer credentials in ~/.aws/credentials to those in ~/.aws/config", async () => {
9921110
__addMatcher(
9931111
join(homedir(), ".aws", "credentials"),

Diff for: packages/credential-provider-ini/src/index.ts

+12-47
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,11 @@
11
import { fromEnv } from "@aws-sdk/credential-provider-env";
22
import { fromContainerMetadata, fromInstanceMetadata } from "@aws-sdk/credential-provider-imds";
3+
import { fromSSO, isSsoProfile, validateSsoProfile } from "@aws-sdk/credential-provider-sso";
34
import { AssumeRoleWithWebIdentityParams, fromTokenFile } from "@aws-sdk/credential-provider-web-identity";
45
import { CredentialsProviderError } from "@aws-sdk/property-provider";
5-
import {
6-
loadSharedConfigFiles,
7-
ParsedIniData,
8-
Profile,
9-
SharedConfigFiles,
10-
SharedConfigInit,
11-
} from "@aws-sdk/shared-ini-file-loader";
6+
import { ParsedIniData, Profile } from "@aws-sdk/shared-ini-file-loader";
127
import { CredentialProvider, Credentials } from "@aws-sdk/types";
13-
14-
const DEFAULT_PROFILE = "default";
15-
export const ENV_PROFILE = "AWS_PROFILE";
8+
import { getMasterProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/util-credentials";
169

1710
/**
1811
* @see http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/STS.html#assumeRole-property
@@ -47,21 +40,6 @@ export interface AssumeRoleParams {
4740
TokenCode?: string;
4841
}
4942

50-
export interface SourceProfileInit extends SharedConfigInit {
51-
/**
52-
* The configuration profile to use.
53-
*/
54-
profile?: string;
55-
56-
/**
57-
* A promise that will be resolved with loaded and parsed credentials files.
58-
* Used to avoid loading shared config files multiple times.
59-
*
60-
* @internal
61-
*/
62-
loadedConfig?: Promise<SharedConfigFiles>;
63-
}
64-
6543
export interface FromIniInit extends SourceProfileInit {
6644
/**
6745
* A function that returns a promise fulfilled with an MFA token code for
@@ -153,28 +131,6 @@ export const fromIni =
153131
return resolveProfileData(getMasterProfileName(init), profiles, init);
154132
};
155133

156-
/**
157-
* Load profiles from credentials and config INI files and normalize them into a
158-
* single profile list.
159-
*
160-
* @internal
161-
*/
162-
export const parseKnownFiles = async (init: SourceProfileInit): Promise<ParsedIniData> => {
163-
const { loadedConfig = loadSharedConfigFiles(init) } = init;
164-
165-
const parsedFiles = await loadedConfig;
166-
return {
167-
...parsedFiles.configFile,
168-
...parsedFiles.credentialsFile,
169-
};
170-
};
171-
172-
/**
173-
* @internal
174-
*/
175-
export const getMasterProfileName = (init: { profile?: string }): string =>
176-
init.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE;
177-
178134
const resolveProfileData = async (
179135
profileName: string,
180136
profiles: ParsedIniData,
@@ -251,6 +207,15 @@ const resolveProfileData = async (
251207
if (isWebIdentityProfile(data)) {
252208
return resolveWebIdentityCredentials(data, options);
253209
}
210+
if (isSsoProfile(data)) {
211+
const { sso_start_url, sso_account_id, sso_region, sso_role_name } = validateSsoProfile(data);
212+
return fromSSO({
213+
ssoStartUrl: sso_start_url,
214+
ssoAccountId: sso_account_id,
215+
ssoRegion: sso_region,
216+
ssoRoleName: sso_role_name,
217+
})();
218+
}
254219

255220
// If the profile cannot be parsed or contains neither static credentials
256221
// nor role assumption metadata, throw an error. This should be considered a

Diff for: packages/credential-provider-node/src/index.spec.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ jest.mock("@aws-sdk/credential-provider-ini", () => {
2727
fromIni: jest.fn().mockReturnValue(iniProvider),
2828
};
2929
});
30-
import { ENV_PROFILE, fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
30+
import { fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
3131
import { ENV_CONFIG_PATH, ENV_CREDENTIALS_PATH } from "@aws-sdk/shared-ini-file-loader";
32+
import { ENV_PROFILE } from "@aws-sdk/util-credentials";
3233

3334
jest.mock("@aws-sdk/credential-provider-process", () => {
3435
const processProvider = jest.fn();

Diff for: packages/credential-provider-node/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import {
66
fromInstanceMetadata,
77
RemoteProviderInit,
88
} from "@aws-sdk/credential-provider-imds";
9-
import { ENV_PROFILE, fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
9+
import { fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
1010
import { fromProcess, FromProcessInit } from "@aws-sdk/credential-provider-process";
1111
import { fromSSO, FromSSOInit } from "@aws-sdk/credential-provider-sso";
1212
import { fromTokenFile, FromTokenFileInit } from "@aws-sdk/credential-provider-web-identity";
1313
import { chain, CredentialsProviderError, memoize } from "@aws-sdk/property-provider";
1414
import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
1515
import { CredentialProvider } from "@aws-sdk/types";
16+
import { ENV_PROFILE } from "@aws-sdk/util-credentials";
1617

1718
export const ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED";
1819

Diff for: packages/credential-provider-process/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@
2121
},
2222
"license": "Apache-2.0",
2323
"dependencies": {
24-
"@aws-sdk/credential-provider-ini": "3.20.0",
2524
"@aws-sdk/property-provider": "3.20.0",
2625
"@aws-sdk/shared-ini-file-loader": "3.20.0",
2726
"@aws-sdk/types": "3.20.0",
27+
"@aws-sdk/util-credentials": "3.0.0",
2828
"tslib": "^2.0.0"
2929
},
3030
"devDependencies": {

Diff for: packages/credential-provider-process/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { getMasterProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/credential-provider-ini";
21
import { CredentialsProviderError } from "@aws-sdk/property-provider";
32
import { ParsedIniData } from "@aws-sdk/shared-ini-file-loader";
43
import { CredentialProvider, Credentials } from "@aws-sdk/types";
4+
import { getMasterProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/util-credentials";
55
import { exec } from "child_process";
66

77
/**

Diff for: packages/credential-provider-sso/README.md

+22-5
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,37 @@
66
## AWS Credential Provider for Node.js - AWS Single Sign-On (SSO)
77

88
This module provides a function, `fromSSO`, that creates
9-
`CredentialProvider` functions that read from [AWS SDKs and Tools
10-
shared configuration and credentials
11-
files](https://docs.aws.amazon.com/credref/latest/refdocs/creds-config-files.html).
12-
Profiles in the `credentials` file are given precedence over
13-
profiles in the `config` file. This provider loads the
9+
`CredentialProvider` functions that read from the
1410
_resolved_ access token from local disk then requests temporary AWS
1511
credentials. For guidance on the AWS Single Sign-On service, please
1612
refer to [AWS's Single Sign-On documentation](https://aws.amazon.com/single-sign-on/).
1713

14+
You can create the `CredentialProvider` functions using the inline SSO
15+
parameters(`ssoStartUrl`, `ssoAccountId`, `ssoRegion`, `ssoRoleName`) or load
16+
them from [AWS SDKs and Tools shared configuration and credentials files](https://docs.aws.amazon.com/credref/latest/refdocs/creds-config-files.html).
17+
Profiles in the `credentials` file are given precedence over
18+
profiles in the `config` file.
19+
20+
This credential provider is intended for use with the AWS SDK for Node.js.
21+
22+
This credential provider **ONLY** supports profiles using the SSO credential. If
23+
you have a profile that assumes a role which derived from the SSO credential,
24+
you should use the `@aws-sdk/credential-provider-ini`, or
25+
`@aws-sdk/credential-provider-node` package.
26+
1827
## Supported configuration
1928

2029
You may customize how credentials are resolved by providing an options hash to
2130
the `fromSSO` factory function. The following options are supported:
2231

32+
- `ssoStartUrl`: The URL to the AWS SSO service. Required if any of the `sso*`
33+
options(except for `ssoClient`) is provided.
34+
- `ssoAccountId`: The ID of the AWS account to use for temporary credentials.
35+
Required if any of the `sso*` options(except for `ssoClient`) is provided.
36+
- `ssoRegion`: The AWS region to use for temporary credentials. Required if any
37+
of the `sso*` options(except for `ssoClient`) is provided.
38+
- `ssoRoleName`: The name of the AWS role to assume. Required if any of the
39+
`sso*` options(except for `ssoClient`) is provided.
2340
- `profile` - The configuration profile to use. If not specified, the provider
2441
will use the value in the `AWS_PROFILE` environment variable or `default` by
2542
default.

0 commit comments

Comments
 (0)