Skip to content

Commit

Permalink
feat(credential-provider-web-identity): support web federated identity (
Browse files Browse the repository at this point in the history
#2203)

Co-authored-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com>
  • Loading branch information
AllanZhengYP and trivikr committed Apr 1, 2021
1 parent f4b4e2a commit ff87e22
Show file tree
Hide file tree
Showing 6 changed files with 395 additions and 183 deletions.
80 changes: 80 additions & 0 deletions packages/credential-provider-web-identity/README.md
Expand Up @@ -7,6 +7,86 @@

This module includes functions which get credentials by calling STS assumeRoleWithWebIdentity API.

## fromWebToken

The function `fromWebToken` returns `CredentialProvider` that get credentials calling sts:assumeRoleWithWebIdentity
API via `roleAssumerWithWebIdentity`.

### Supported configuration

This configuration supports all the input parameters from
[sts:AssumeWithWebIdentity](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-sts/modules/assumerolewithwebidentityrequest.html) API. The following options are supported:

- `roleArn` - The Amazon Resource Name (ARN) of the role that the caller is assuming.
- `webIdentityToken` - The OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity provider.
- `roleSessionName` - An identifier for the assumed role session.
- `providerId` - The fully qualified host component of the domain name of the identity provider. Do not specify this
value for OpenID Connect ID tokens.
- `policyArns` - The Amazon Resource Names (ARNs) of the IAM managed policies that you want to use as managed session
policies.
- `policy` - An IAM policy in JSON format that you want to use as an inline session policy.
- `durationSeconds` - The duration, in seconds, of the role session. Default to 3600.
- `roleAssumerWithWebIdentity` - A function that assumes a role with web identity
and returns a promise fulfilled with credentials for the assumed role. You may call
`sts:assumeRoleWithWebIdentity` API within this function.

### Examples

You can directly configure individual identity providers to access AWS resources using web identity federation. AWS
currently supports authenticating users using web identity federation through several identity providers:

- [Login with Amazon](https://login.amazon.com/)

- [Facebook Login](https://developers.facebook.com/docs/facebook-login/web/)

- [Google Sign-in](https://developers.google.com/identity/)

You must first register your application with the providers that your application supports. Next, create an IAM role and
set up permissions for it. The IAM role you create is then used to grant the permissions you configured for it through
the respective identity provider. For example, you can set up a role that allows users who logged in through Facebook
to have read access to a specific Amazon S3 bucket you control.

After you have both an IAM role with configured privileges and an application registered with your chosen identity
providers, you can set up the SDK to get credentials for the IAM role using helper code, as follows:

```javascript
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { STSClient, AssumeRoleWithWebIdentityCommand } from "@aws-sdk/client-sts";
import { fromWebToken } from "@aws-sdk/credential-provider-web-identity";

const stsClient = new STSClient({});

const roleAssumerWithWebIdentity = async (params) => {
const { Credentials } = await stsClient.send(
new AssumeRoleWithWebIdentityCommand(params)
);
if (!Credentials || !Credentials.AccessKeyId || !Credentials.SecretAccessKey) {
throw new Error(`Invalid response from STS.assumeRole call with role ${params.RoleArn}`);
}
return {
accessKeyId: Credentials.AccessKeyId,
secretAccessKey: Credentials.SecretAccessKey,
sessionToken: Credentials.SessionToken,
expiration: Credentials.Expiration,
};
};

const dynamodb = new DynamoDBClient({
region,
credentials: fromWebToken({
roleArn: 'arn:aws:iam::<AWS_ACCOUNT_ID>/:role/<WEB_IDENTITY_ROLE_NAME>',
providerId: 'graph.facebook.com|www.amazon.com', // this is null for Google
webIdentityToken: ACCESS_TOKEN // from OpenID token identity provider
roleAssumerWithWebIdentity,
})
});

```

The value in the ProviderId parameter depends on the specified identity provider. The value in the WebIdentityToken
parameter is the access token retrieved from a successful login with the identity provider. For more information on how
to configure and retrieve access tokens for each identity provider, see the documentation for the identity provider.

## fromTokenFile

The function `fromTokenFile` returns `CredentialProvider` that reads credentials as follows:
Expand Down
182 changes: 60 additions & 122 deletions packages/credential-provider-web-identity/src/fromTokenFile.spec.ts
@@ -1,7 +1,9 @@
import { ProviderError } from "@aws-sdk/property-provider";
import { readFileSync } from "fs";

import { AssumeRoleWithWebIdentityParams, fromTokenFile, FromTokenFileInit } from "./fromTokenFile";
jest.mock("./fromWebToken", () => ({
fromWebToken: jest.fn().mockReturnValue(() => Promise.resolve(MOCK_CREDS)),
}));
import { fromTokenFile } from "./fromTokenFile";
import { fromWebToken } from "./fromWebToken";

const ENV_TOKEN_FILE = "AWS_WEB_IDENTITY_TOKEN_FILE";
const ENV_ROLE_ARN = "AWS_ROLE_ARN";
Expand Down Expand Up @@ -30,57 +32,6 @@ describe(fromTokenFile.name, () => {
jest.restoreAllMocks();
});

const testRoleAssumerWithWebIdentityNotDefined = async (init: FromTokenFileInit, roleArn: string) => {
try {
// @ts-ignore An argument for 'init' was not provided.
await fromTokenFile(init)();
fail(`Expected error to be thrown`);
} catch (error) {
expect(error).toEqual(
new ProviderError(
`Role Arn '${roleArn}' needs to be assumed with web identity, but no role assumption callback was provided.`,
false
)
);
}
};

const testReadFileSyncError = async (init: FromTokenFileInit) => {
const readFileSyncError = new Error("readFileSyncError");
(readFileSync as jest.Mock).mockImplementation(() => {
throw readFileSyncError;
});
try {
await fromTokenFile(init)();
fail(`Expected error to be thrown`);
} catch (error) {
expect(error).toEqual(readFileSyncError);
}
expect(readFileSync).toHaveBeenCalledTimes(1);
};

const testRoleAssumerWithWebIdentitySuccess = async (init: FromTokenFileInit) => {
const creds = await fromTokenFile(init)();
expect(creds).toEqual(MOCK_CREDS);
expect(readFileSync).toHaveBeenCalledTimes(1);
expect(readFileSync).toHaveBeenCalledWith(mockTokenFile, { encoding: "ascii" });
};

const testRandomValueForRoleSessionName = async (init: FromTokenFileInit) => {
const mockDateNow = Date.now();
const spyDateNow = jest.spyOn(Date, "now").mockReturnValueOnce(mockDateNow);

const creds = await fromTokenFile({
...init,
roleAssumerWithWebIdentity: async (params: AssumeRoleWithWebIdentityParams) => {
expect(params.RoleSessionName).toEqual(`aws-sdk-js-session-${mockDateNow}`);
return MOCK_CREDS;
},
})();
expect(creds).toEqual(MOCK_CREDS);
expect(spyDateNow).toHaveBeenCalledTimes(1);
};

describe("reads config from env", () => {
const original_ENV_TOKEN_FILE = process.env[ENV_TOKEN_FILE];
const original_ENV_ROLE_ARN = process.env[ENV_ROLE_ARN];
Expand All @@ -98,83 +49,70 @@ describe(fromTokenFile.name, () => {
process.env[ENV_ROLE_SESSION_NAME] = original_ENV_ROLE_SESSION_NAME;
});

it("throws if roleAssumerWithWebIdentity is not defined", async () => {
return testRoleAssumerWithWebIdentityNotDefined({}, process.env[ENV_ROLE_ARN]);
it(`passes values to ${fromWebToken.name}`, async () => {
const roleAssumerWithWebIdentity = jest.fn();
const creds = await fromTokenFile({
roleAssumerWithWebIdentity,
})();
expect(creds).toEqual(MOCK_CREDS);
expect(fromWebToken as jest.Mock).toBeCalledTimes(1);
const webTokenInit = (fromWebToken as jest.Mock).mock.calls[0][0];
expect(webTokenInit.webIdentityToken).toBe(mockTokenValue);
expect(webTokenInit.roleSessionName).toBe(mockRoleSessionName);
expect(webTokenInit.roleArn).toBe(mockRoleArn);
expect(webTokenInit.roleAssumerWithWebIdentity).toBe(roleAssumerWithWebIdentity);
});

it("throws if ENV_TOKEN_FILE read from disk failed", async () => {
return testReadFileSyncError({
roleAssumerWithWebIdentity: async (params: AssumeRoleWithWebIdentityParams) => {
return MOCK_CREDS;
},
});
it("prefers init parameters over environmental variables", async () => {
const roleAssumerWithWebIdentity = jest.fn();
const init = {
webIdentityTokenFile: "anotherTokenFile",
roleArn: "anotherRoleArn",
roleSessionName: "anotherRoleSessionName",
roleAssumerWithWebIdentity,
};
const creds = await fromTokenFile(init)();
expect(creds).toEqual(MOCK_CREDS);
expect(fromWebToken as jest.Mock).toBeCalledTimes(1);
const webTokenInit = (fromWebToken as jest.Mock).mock.calls[0][0];
expect(webTokenInit.roleSessionName).toBe(init.roleSessionName);
expect(webTokenInit.roleArn).toBe(init.roleArn);
expect(webTokenInit.roleAssumerWithWebIdentity).toBe(roleAssumerWithWebIdentity);
expect(readFileSync as jest.Mock).toBeCalledTimes(1);
expect((readFileSync as jest.Mock).mock.calls[0][0]).toBe(init.webIdentityTokenFile);
});

it("passes values to roleAssumerWithWebIdentity", async () => {
return testRoleAssumerWithWebIdentitySuccess({
roleAssumerWithWebIdentity: async (params: AssumeRoleWithWebIdentityParams) => {
expect(params.WebIdentityToken).toEqual(mockTokenValue);
expect(params.RoleArn).toEqual(mockRoleArn);
expect(params.RoleSessionName).toEqual(mockRoleSessionName);
return MOCK_CREDS;
},
it("throws if ENV_TOKEN_FILE read from disk failed", async () => {
const readFileSyncError = new Error("readFileSyncError");
(readFileSync as jest.Mock).mockImplementation(() => {
throw readFileSyncError;
});
});

it("generates a random value for RoleSessionName if not available", async () => {
delete process.env[ENV_ROLE_SESSION_NAME];
return testRandomValueForRoleSessionName({});
});
});

describe("reads config from configuration keys", () => {
const original_ENV_TOKEN_FILE = process.env[ENV_TOKEN_FILE];
const original_ENV_ROLE_ARN = process.env[ENV_ROLE_ARN];
const original_ENV_ROLE_SESSION_NAME = process.env[ENV_ROLE_SESSION_NAME];

beforeAll(() => {
delete process.env[ENV_TOKEN_FILE];
delete process.env[ENV_ROLE_ARN];
delete process.env[ENV_ROLE_SESSION_NAME];
});

afterAll(() => {
process.env[ENV_TOKEN_FILE] = original_ENV_TOKEN_FILE;
process.env[ENV_ROLE_ARN] = original_ENV_ROLE_ARN;
process.env[ENV_ROLE_SESSION_NAME] = original_ENV_ROLE_SESSION_NAME;
});

it("throws if roleAssumerWithWebIdentity is not defined", async () => {
return testRoleAssumerWithWebIdentityNotDefined({ roleArn: mockRoleArn }, mockRoleArn);
try {
await fromTokenFile({ roleAssumerWithWebIdentity: jest.fn() })();
fail(`Expected error to be thrown`);
} catch (error) {
expect(error).toEqual(readFileSyncError);
}
expect(readFileSync).toHaveBeenCalledTimes(1);
});

it("throws if web_identity_token_file read from disk failed", async () => {
return testReadFileSyncError({
webIdentityTokenFile: mockTokenFile,
roleArn: mockRoleArn,
roleSessionName: mockRoleSessionName,
roleAssumerWithWebIdentity: async (params: AssumeRoleWithWebIdentityParams) => {
return MOCK_CREDS;
},
});
});

it("passes values to roleAssumerWithWebIdentity", async () => {
return testRoleAssumerWithWebIdentitySuccess({
webIdentityTokenFile: mockTokenFile,
roleArn: mockRoleArn,
roleSessionName: mockRoleSessionName,
roleAssumerWithWebIdentity: async (params: AssumeRoleWithWebIdentityParams) => {
expect(params.WebIdentityToken).toEqual(mockTokenValue);
expect(params.RoleArn).toEqual(mockRoleArn);
expect(params.RoleSessionName).toEqual(mockRoleSessionName);
return MOCK_CREDS;
},
const readFileSyncError = new Error("readFileSyncError");
(readFileSync as jest.Mock).mockImplementation(() => {
throw readFileSyncError;
});
});

it("generates a random value for RoleSessionName if not available", async () => {
return testRandomValueForRoleSessionName({ webIdentityTokenFile: mockTokenFile, roleArn: mockRoleArn });
try {
await fromTokenFile({
webIdentityTokenFile: mockTokenFile,
roleArn: mockRoleArn,
roleSessionName: mockRoleSessionName,
roleAssumerWithWebIdentity: jest.fn(),
})();
fail(`Expected error to be thrown`);
} catch (error) {
expect(error).toEqual(readFileSyncError);
}
expect(readFileSync).toHaveBeenCalledTimes(1);
});
});
});
73 changes: 12 additions & 61 deletions packages/credential-provider-web-identity/src/fromTokenFile.ts
@@ -1,78 +1,29 @@
import { ProviderError } from "@aws-sdk/property-provider";
import { CredentialProvider, Credentials } from "@aws-sdk/types";
import { CredentialProvider } from "@aws-sdk/types";
import { readFileSync } from "fs";

import { fromWebToken, FromWebTokenInit } from "./fromWebToken";

const ENV_TOKEN_FILE = "AWS_WEB_IDENTITY_TOKEN_FILE";
const ENV_ROLE_ARN = "AWS_ROLE_ARN";
const ENV_ROLE_SESSION_NAME = "AWS_ROLE_SESSION_NAME";

export interface AssumeRoleWithWebIdentityParams {
/**
* <p>The Amazon Resource Name (ARN) of the role that the caller is assuming.</p>
*/
RoleArn: string;
/**
* <p>An identifier for the assumed role session. Typically, you pass the name or identifier
* that is associated with the user who is using your application. That way, the temporary
* security credentials that your application will use are associated with that user. This
* session name is included as part of the ARN and assumed role ID in the
* <code>AssumedRoleUser</code> response element.</p>
* <p>The regex used to validate this parameter is a string of characters
* consisting of upper- and lower-case alphanumeric characters with no spaces. You can
* also include underscores or any of the following characters: =,.@-</p>
*/
RoleSessionName: string;
/**
* <p>The OAuth 2.0 access token or OpenID Connect ID token that is provided by the identity
* provider. Your application must get this token by authenticating the user who is using your
* application with a web identity provider before the application makes an
* <code>AssumeRoleWithWebIdentity</code> call. </p>
*/
WebIdentityToken: string;
}
export interface FromTokenFileInit {
export interface FromTokenFileInit extends Partial<Omit<FromWebTokenInit, "webIdentityToken">> {
/**
* File location of where the `OIDC` token is stored.
*/
webIdentityTokenFile?: string;

/**
* The IAM role wanting to be assumed.
*/
roleArn?: string;

/**
* The IAM session name used to distinguish sessions.
*/
roleSessionName?: string;

/**
* A function that assumes a role with web identity and returns a promise fulfilled with
* credentials for the assumed role.
*
* @param sourceCreds The credentials with which to assume a role.
* @param params
*/
roleAssumerWithWebIdentity?: (params: AssumeRoleWithWebIdentityParams) => Promise<Credentials>;
}

/**
* Represents OIDC credentials from a file on disk.
*/
export const fromTokenFile = (init: FromTokenFileInit): CredentialProvider => async () => {
const { webIdentityTokenFile, roleArn, roleSessionName, roleAssumerWithWebIdentity } = init;

if (!roleAssumerWithWebIdentity) {
throw new ProviderError(
`Role Arn '${roleArn ?? process.env[ENV_ROLE_ARN]}' needs to be assumed with web identity,` +
` but no role assumption callback was provided.`,
false
);
}

return roleAssumerWithWebIdentity({
WebIdentityToken: readFileSync(webIdentityTokenFile ?? process.env[ENV_TOKEN_FILE]!, { encoding: "ascii" }),
RoleArn: roleArn ?? process.env[ENV_ROLE_ARN]!,
RoleSessionName: roleSessionName ?? process.env[ENV_ROLE_SESSION_NAME] ?? `aws-sdk-js-session-${Date.now()}`,
export const fromTokenFile = (init: FromTokenFileInit): CredentialProvider => {
const { webIdentityTokenFile, roleArn, roleSessionName } = init;

return fromWebToken({
...init,
webIdentityToken: readFileSync(webIdentityTokenFile ?? process.env[ENV_TOKEN_FILE]!, { encoding: "ascii" }),
roleArn: roleArn ?? process.env[ENV_ROLE_ARN]!,
roleSessionName: roleSessionName ?? process.env[ENV_ROLE_SESSION_NAME],
});
};

0 comments on commit ff87e22

Please sign in to comment.