Skip to content

feat(credential-provider-sso): support sso credential when resolving shared credential file #2583

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

Merged
merged 5 commits into from
Jul 16, 2021
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
43 changes: 42 additions & 1 deletion packages/credential-provider-ini/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ aws_access_key_id=foo
aws_secret_access_key=bar

[first]
source_profile=first
source_profile=second
role_arn=arn:aws:iam::123456789012:role/example-role-arn
```

Expand Down Expand Up @@ -125,3 +125,44 @@ credential_source = EcsContainer
web_identity_token_file=/temp/token
role_arn=arn:aws:iam::123456789012:role/example-role-arn
```

You can specify another profile(`second`) whose credentials are used to assume
the role by the `role_arn` setting in this profile(`first`).

```ini
[second]
web_identity_token_file=/temp/token
role_arn=arn:aws:iam::123456789012:role/example-role-2

[first]
source_profile=second
role_arn=arn:aws:iam::123456789012:role/example-role
```

### profile with sso credentials

Please refer the the [`sso credential provider package`](https://www.npmjs.com/package/@aws-sdk/credential-provider-sso)
for how to configure the SSO credentials.

```ini
[default]
sso_account_id = 012345678901
sso_region = us-east-1
sso_role_name = SampleRole
sso_start_url = https://d-abc123.awsapps.com/start
```

You can specify another profile(`second`) whose credentials derived from SSO
are used to assume the role by the `role_arn` setting in this profile(`first`).

```ini
[second]
sso_account_id = 012345678901
sso_region = us-east-1
sso_role_name = example-role-2
sso_start_url = https://d-abc123.awsapps.com/start

[first]
source_profile=second
role_arn=arn:aws:iam::123456789012:role/example-role
```
2 changes: 2 additions & 0 deletions packages/credential-provider-ini/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
"dependencies": {
"@aws-sdk/credential-provider-env": "3.20.0",
"@aws-sdk/credential-provider-imds": "3.20.0",
"@aws-sdk/credential-provider-sso": "3.21.0",
"@aws-sdk/credential-provider-web-identity": "3.20.0",
"@aws-sdk/property-provider": "3.20.0",
"@aws-sdk/shared-ini-file-loader": "3.20.0",
"@aws-sdk/types": "3.20.0",
"@aws-sdk/util-credentials": "3.0.0",
"tslib": "^2.0.0"
},
"devDependencies": {
Expand Down
120 changes: 119 additions & 1 deletion packages/credential-provider-ini/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { fromEnv } from "@aws-sdk/credential-provider-env";
import { fromContainerMetadata, fromInstanceMetadata } from "@aws-sdk/credential-provider-imds";
import { fromSSO, isSsoProfile, validateSsoProfile } from "@aws-sdk/credential-provider-sso";
import { fromTokenFile } from "@aws-sdk/credential-provider-web-identity";
import { ENV_CONFIG_PATH, ENV_CREDENTIALS_PATH } from "@aws-sdk/shared-ini-file-loader";
import { Credentials } from "@aws-sdk/types";
import { ENV_PROFILE } from "@aws-sdk/util-credentials";
import { join, sep } from "path";

import { AssumeRoleParams, ENV_PROFILE, fromIni } from "./";
import { AssumeRoleParams, fromIni } from "./";

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

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

jest.mock("@aws-sdk/credential-provider-sso");

const DEFAULT_CREDS = {
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
Expand Down Expand Up @@ -988,6 +992,120 @@ role_arn = ${roleArn}`.trim()
});
});

describe("assume role with SSO", () => {
const DEFAULT_PATH = join(homedir(), ".aws", "credentials");
it("should continue if profile is not configured with an SSO credential", async () => {
__addMatcher(
DEFAULT_PATH,
`[default]
aws_access_key_id = ${DEFAULT_CREDS.accessKeyId}
aws_secret_access_key = ${DEFAULT_CREDS.secretAccessKey}
aws_session_token = ${DEFAULT_CREDS.sessionToken}
`.trim()
);
await fromIni()();
expect(fromSSO).not.toHaveBeenCalled();
});

it("should throw if profile is configured with incomplete SSO credential", async () => {
(isSsoProfile as unknown as jest.Mock).mockImplementationOnce(() => true);
const originalValidator = jest.requireActual("@aws-sdk/credential-provider-sso").validateSsoProfile;
(validateSsoProfile as unknown as jest.Mock).mockImplementationOnce(originalValidator);
__addMatcher(
DEFAULT_PATH,
`[default]
sso_account_id = 1234567890
sso_start_url = https://example.com/sso/
`.trim()
);
try {
await fromIni()();
} catch (e) {
console.error(e.message);
expect(e.message).toEqual(expect.stringContaining("Profile is configured with invalid SSO credentials"));
}
});

it("should resolve valid SSO credential", async () => {
(isSsoProfile as unknown as jest.Mock).mockImplementationOnce(() => true);
const originalValidator = jest.requireActual("@aws-sdk/credential-provider-sso").validateSsoProfile;
(validateSsoProfile as jest.Mock).mockImplementationOnce(originalValidator);
(fromSSO as jest.Mock).mockImplementationOnce(() => async () => DEFAULT_CREDS);
const accountId = "1234567890";
const startUrl = "https://example.com/sso/";
const region = "us-east-1";
const roleName = "role";
__addMatcher(
DEFAULT_PATH,
`[default]
sso_account_id = ${accountId}
sso_start_url = ${startUrl}
sso_region = ${region}
sso_role_name = ${roleName}
`.trim()
);
await fromIni()();
expect(fromSSO as unknown as jest.Mock).toBeCalledWith({
ssoAccountId: accountId,
ssoStartUrl: startUrl,
ssoRegion: region,
ssoRoleName: roleName,
});
});

it("should call fromTokenFile with assume role chaining", async () => {
(isSsoProfile as unknown as jest.Mock).mockImplementationOnce(
jest.requireActual("@aws-sdk/credential-provider-sso").isSsoProfile
);
(validateSsoProfile as unknown as jest.Mock).mockImplementationOnce(
jest.requireActual("@aws-sdk/credential-provider-sso").validateSsoProfile
);
(fromSSO as jest.Mock).mockImplementationOnce(() => async () => DEFAULT_CREDS);
const accountId = "1234567890";
const startUrl = "https://example.com/sso/";
const region = "us-east-1";
const roleName = "role";
const roleAssumerWithWebIdentity = jest.fn();

const fooRoleArn = "arn:aws:iam::123456789:role/foo";
const fooSessionName = "fooSession";
__addMatcher(
DEFAULT_PATH,
`
[bar]
sso_account_id = ${accountId}
sso_start_url = ${startUrl}
sso_region = ${region}
sso_role_name = ${roleName}

[foo]
role_arn = ${fooRoleArn}
role_session_name = ${fooSessionName}
source_profile = bar`.trim()
);

const provider = fromIni({
profile: "foo",
roleAssumer(sourceCreds: Credentials, params: AssumeRoleParams): Promise<Credentials> {
expect(sourceCreds).toEqual(DEFAULT_CREDS);
expect(params.RoleArn).toEqual(fooRoleArn);
expect(params.RoleSessionName).toEqual(fooSessionName);
return Promise.resolve(FOO_CREDS);
},
roleAssumerWithWebIdentity,
});

expect(await provider()).toEqual(FOO_CREDS);
expect(fromSSO).toHaveBeenCalledTimes(1);
expect(fromSSO).toHaveBeenCalledWith({
ssoAccountId: accountId,
ssoStartUrl: startUrl,
ssoRegion: region,
ssoRoleName: roleName,
});
});
});

it("should prefer credentials in ~/.aws/credentials to those in ~/.aws/config", async () => {
__addMatcher(
join(homedir(), ".aws", "credentials"),
Expand Down
59 changes: 12 additions & 47 deletions packages/credential-provider-ini/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { fromEnv } from "@aws-sdk/credential-provider-env";
import { fromContainerMetadata, fromInstanceMetadata } from "@aws-sdk/credential-provider-imds";
import { fromSSO, isSsoProfile, validateSsoProfile } from "@aws-sdk/credential-provider-sso";
import { AssumeRoleWithWebIdentityParams, fromTokenFile } from "@aws-sdk/credential-provider-web-identity";
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import {
loadSharedConfigFiles,
ParsedIniData,
Profile,
SharedConfigFiles,
SharedConfigInit,
} from "@aws-sdk/shared-ini-file-loader";
import { ParsedIniData, Profile } from "@aws-sdk/shared-ini-file-loader";
import { CredentialProvider, Credentials } from "@aws-sdk/types";

const DEFAULT_PROFILE = "default";
export const ENV_PROFILE = "AWS_PROFILE";
import { getMasterProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/util-credentials";

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

export interface SourceProfileInit extends SharedConfigInit {
/**
* The configuration profile to use.
*/
profile?: string;

/**
* A promise that will be resolved with loaded and parsed credentials files.
* Used to avoid loading shared config files multiple times.
*
* @internal
*/
loadedConfig?: Promise<SharedConfigFiles>;
}

export interface FromIniInit extends SourceProfileInit {
/**
* A function that returns a promise fulfilled with an MFA token code for
Expand Down Expand Up @@ -153,28 +131,6 @@ export const fromIni =
return resolveProfileData(getMasterProfileName(init), profiles, init);
};

/**
* Load profiles from credentials and config INI files and normalize them into a
* single profile list.
*
* @internal
*/
export const parseKnownFiles = async (init: SourceProfileInit): Promise<ParsedIniData> => {
const { loadedConfig = loadSharedConfigFiles(init) } = init;

const parsedFiles = await loadedConfig;
return {
...parsedFiles.configFile,
...parsedFiles.credentialsFile,
};
};

/**
* @internal
*/
export const getMasterProfileName = (init: { profile?: string }): string =>
init.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE;

const resolveProfileData = async (
profileName: string,
profiles: ParsedIniData,
Expand Down Expand Up @@ -251,6 +207,15 @@ const resolveProfileData = async (
if (isWebIdentityProfile(data)) {
return resolveWebIdentityCredentials(data, options);
}
if (isSsoProfile(data)) {
const { sso_start_url, sso_account_id, sso_region, sso_role_name } = validateSsoProfile(data);
return fromSSO({
ssoStartUrl: sso_start_url,
ssoAccountId: sso_account_id,
ssoRegion: sso_region,
ssoRoleName: sso_role_name,
})();
}

// If the profile cannot be parsed or contains neither static credentials
// nor role assumption metadata, throw an error. This should be considered a
Expand Down
3 changes: 2 additions & 1 deletion packages/credential-provider-node/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ jest.mock("@aws-sdk/credential-provider-ini", () => {
fromIni: jest.fn().mockReturnValue(iniProvider),
};
});
import { ENV_PROFILE, fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
import { fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
import { ENV_CONFIG_PATH, ENV_CREDENTIALS_PATH } from "@aws-sdk/shared-ini-file-loader";
import { ENV_PROFILE } from "@aws-sdk/util-credentials";

jest.mock("@aws-sdk/credential-provider-process", () => {
const processProvider = jest.fn();
Expand Down
3 changes: 2 additions & 1 deletion packages/credential-provider-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import {
fromInstanceMetadata,
RemoteProviderInit,
} from "@aws-sdk/credential-provider-imds";
import { ENV_PROFILE, fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
import { fromIni, FromIniInit } from "@aws-sdk/credential-provider-ini";
import { fromProcess, FromProcessInit } from "@aws-sdk/credential-provider-process";
import { fromSSO, FromSSOInit } from "@aws-sdk/credential-provider-sso";
import { fromTokenFile, FromTokenFileInit } from "@aws-sdk/credential-provider-web-identity";
import { chain, CredentialsProviderError, memoize } from "@aws-sdk/property-provider";
import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
import { CredentialProvider } from "@aws-sdk/types";
import { ENV_PROFILE } from "@aws-sdk/util-credentials";

export const ENV_IMDS_DISABLED = "AWS_EC2_METADATA_DISABLED";

Expand Down
2 changes: 1 addition & 1 deletion packages/credential-provider-process/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
},
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-ini": "3.20.0",
"@aws-sdk/property-provider": "3.20.0",
"@aws-sdk/shared-ini-file-loader": "3.20.0",
"@aws-sdk/types": "3.20.0",
"@aws-sdk/util-credentials": "3.0.0",
"tslib": "^2.0.0"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/credential-provider-process/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getMasterProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/credential-provider-ini";
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import { ParsedIniData } from "@aws-sdk/shared-ini-file-loader";
import { CredentialProvider, Credentials } from "@aws-sdk/types";
import { getMasterProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/util-credentials";
import { exec } from "child_process";

/**
Expand Down
27 changes: 22 additions & 5 deletions packages/credential-provider-sso/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,37 @@
## AWS Credential Provider for Node.js - AWS Single Sign-On (SSO)

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

You can create the `CredentialProvider` functions using the inline SSO
parameters(`ssoStartUrl`, `ssoAccountId`, `ssoRegion`, `ssoRoleName`) or load
them from [AWS SDKs and Tools shared configuration and credentials files](https://docs.aws.amazon.com/credref/latest/refdocs/creds-config-files.html).
Profiles in the `credentials` file are given precedence over
profiles in the `config` file.

This credential provider is intended for use with the AWS SDK for Node.js.

This credential provider **ONLY** supports profiles using the SSO credential. If
you have a profile that assumes a role which derived from the SSO credential,
you should use the `@aws-sdk/credential-provider-ini`, or
`@aws-sdk/credential-provider-node` package.

## Supported configuration

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

- `ssoStartUrl`: The URL to the AWS SSO service. Required if any of the `sso*`
options(except for `ssoClient`) is provided.
- `ssoAccountId`: The ID of the AWS account to use for temporary credentials.
Required if any of the `sso*` options(except for `ssoClient`) is provided.
- `ssoRegion`: The AWS region to use for temporary credentials. Required if any
of the `sso*` options(except for `ssoClient`) is provided.
- `ssoRoleName`: The name of the AWS role to assume. Required if any of the
`sso*` options(except for `ssoClient`) is provided.
- `profile` - The configuration profile to use. If not specified, the provider
will use the value in the `AWS_PROFILE` environment variable or `default` by
default.
Expand Down
Loading