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

feat: refactor AWS and identity pool clients to use suppliers #1776

Merged
merged 7 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
244 changes: 60 additions & 184 deletions src/auth/awsclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import {GaxiosOptions} from 'gaxios';
import {Gaxios} from 'gaxios';

import {AwsRequestSigner, AwsSecurityCredentials} from './awsrequestsigner';
import {
BaseExternalAccountClient,
BaseExternalAccountClientOptions,
ExternalAccountSupplierContext,
} from './baseexternalclient';
import {Headers} from './oauth2client';
import {AuthClientOptions} from './authclient';
import {Transporter} from '../transporters';
import {DefaultAwsSecurityCredentialsSupplier} from './defaultawssecuritycredentialssupplier';

/**
* AWS credentials JSON interface. This is used for AWS workloads.
Expand All @@ -47,16 +49,40 @@ export interface AwsClientOptions extends BaseExternalAccountClientOptions {
}

/**
* Interface defining the AWS security-credentials endpoint response.
* Supplier interface for AWS security credentials. This can be implemented to
* return an AWS region and AWS security credentials. These credentials can
* then be exchanged for a GCP token by an {@link AwsClient}.
*/
interface AwsSecurityCredentialsResponse {
Code: string;
LastUpdated: string;
Type: string;
AccessKeyId: string;
SecretAccessKey: string;
Token: string;
Expiration: string;
export interface AwsSecurityCredentialsSupplier {
/**
* Gets the active AWS region.
* @param context {@link ExternalAccountSupplierContext} from the calling
* {@link AwsClient}, contains the requested audience and subject token type
* for the external account identity. Not used.
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
* @param transporter The {@link Gaxios} or {@link Transporter} instance from
* the calling {@link AwsClient} to use for requests.
* @return A promise that resolves with the AWS region string.
*/
getAwsRegion: (
context: ExternalAccountSupplierContext,
transporter: Transporter | Gaxios
) => Promise<string>;

/**
* Gets valid AWS security credentials for the requested external account
* identity. Note that these are not cached by the calling {@link AwsClient},
* so caching should be including in the implementation.
* @param context {@link ExternalAccountSupplierContext} from the calling
* {@link AwsClient}, contains the requested audience and subject token type
* for the external account identity. Not used.
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
* @param Transporter The {@link Gaxios} or {@link Transporter} instance from
* the calling {@link AwsClient} to use for requests.
* @return A promise that resolves with the requested {@link AwsSecurityCredentials}.
*/
getAwsSecurityCredentials: (
context: ExternalAccountSupplierContext,
transporter: Transporter | Gaxios
) => Promise<AwsSecurityCredentials>;
}

/**
Expand All @@ -66,16 +92,11 @@ interface AwsSecurityCredentialsResponse {
*/
export class AwsClient extends BaseExternalAccountClient {
private readonly environmentId: string;
private readonly regionUrl?: string;
private readonly securityCredentialsUrl?: string;
private readonly awsSecurityCredentialsSupplier: AwsSecurityCredentialsSupplier;
private readonly regionalCredVerificationUrl: string;
private readonly imdsV2SessionTokenUrl?: string;
private awsRequestSigner: AwsRequestSigner | null;
private region: string;

static AWS_EC2_METADATA_IPV4_ADDRESS = '169.254.169.254';
static AWS_EC2_METADATA_IPV6_ADDRESS = 'fd00:ec2::254';
aeitzman marked this conversation as resolved.
Show resolved Hide resolved

/**
* Instantiates an AwsClient instance using the provided JSON
* object loaded from an external account credentials file.
Expand All @@ -95,14 +116,21 @@ export class AwsClient extends BaseExternalAccountClient {
this.environmentId = options.credential_source.environment_id;
// This is only required if the AWS region is not available in the
// AWS_REGION or AWS_DEFAULT_REGION environment variables.
this.regionUrl = options.credential_source.region_url;
const regionUrl = options.credential_source.region_url;
// This is only required if AWS security credentials are not available in
// environment variables.
this.securityCredentialsUrl = options.credential_source.url;
const securityCredentialsUrl = options.credential_source.url;
const imdsV2SessionTokenUrl =
options.credential_source.imdsv2_session_token_url;
this.awsSecurityCredentialsSupplier =
new DefaultAwsSecurityCredentialsSupplier(
regionUrl,
securityCredentialsUrl,
imdsV2SessionTokenUrl
);

this.regionalCredVerificationUrl =
options.credential_source.regional_cred_verification_url;
this.imdsV2SessionTokenUrl =
options.credential_source.imdsv2_session_token_url;
this.awsRequestSigner = null;
this.region = '';
this.credentialSourceType = 'aws';
Expand All @@ -124,68 +152,24 @@ export class AwsClient extends BaseExternalAccountClient {

/**
* Triggered when an external subject token is needed to be exchanged for a
* GCP access token via GCP STS endpoint.
* This uses the `options.credential_source` object to figure out how
* to retrieve the token using the current environment. In this case,
* this uses a serialized AWS signed request to the STS GetCallerIdentity
* endpoint.
* The logic is summarized as:
* 1. If imdsv2_session_token_url is provided in the credential source, then
* fetch the aws session token and include it in the headers of the
* metadata requests. This is a requirement for IDMSv2 but optional
* for IDMSv1.
* 2. Retrieve AWS region from availability-zone.
* 3a. Check AWS credentials in environment variables. If not found, get
* from security-credentials endpoint.
* 3b. Get AWS credentials from security-credentials endpoint. In order
* to retrieve this, the AWS role needs to be determined by calling
* security-credentials endpoint without any argument. Then the
* credentials can be retrieved via: security-credentials/role_name
* 4. Generate the signed request to AWS STS GetCallerIdentity action.
* 5. Inject x-goog-cloud-target-resource into header and serialize the
* signed request. This will be the subject-token to pass to GCP STS.
* GCP access token via GCP STS endpoint. This will call the
* {@link AwsSecurityCredentialsSupplier} to retrieve an AWS region and AWS
* Security Credentials, then use them to create a signed AWS STS request that
* can be exchanged for a GCP access token.
* @return A promise that resolves with the external subject token.
*/
async retrieveSubjectToken(): Promise<string> {
// Initialize AWS request signer if not already initialized.
if (!this.awsRequestSigner) {
const metadataHeaders: Headers = {};
// Only retrieve the IMDSv2 session token if both the security credentials and region are
// not retrievable through the environment.
// The credential config contains all the URLs by default but clients may be running this
// where the metadata server is not available and returning the credentials through the environment.
// Removing this check may break them.
if (!this.regionFromEnv && this.imdsV2SessionTokenUrl) {
metadataHeaders['x-aws-ec2-metadata-token'] =
await this.getImdsV2SessionToken();
}

this.region = await this.getAwsRegion(metadataHeaders);
this.region = await this.awsSecurityCredentialsSupplier.getAwsRegion(
this.supplierContext,
this.transporter
);
this.awsRequestSigner = new AwsRequestSigner(async () => {
// Check environment variables for permanent credentials first.
// https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
if (this.securityCredentialsFromEnv) {
return this.securityCredentialsFromEnv;
}
if (this.imdsV2SessionTokenUrl) {
metadataHeaders['x-aws-ec2-metadata-token'] =
await this.getImdsV2SessionToken();
}
// Since the role on a VM can change, we don't need to cache it.
const roleName = await this.getAwsRoleName(metadataHeaders);
// Temporary credentials typically last for several hours.
// Expiration is returned in response.
// Consider future optimization of this logic to cache AWS tokens
// until their natural expiration.
const awsCreds = await this.getAwsSecurityCredentials(
roleName,
metadataHeaders
return this.awsSecurityCredentialsSupplier.getAwsSecurityCredentials(
this.supplierContext,
this.transporter
);
return {
accessKeyId: awsCreds.AccessKeyId,
secretAccessKey: awsCreds.SecretAccessKey,
token: awsCreds.Token,
};
}, this.region);
}

Expand Down Expand Up @@ -234,112 +218,4 @@ export class AwsClient extends BaseExternalAccountClient {
})
);
}

/**
* @return A promise that resolves with the IMDSv2 Session Token.
*/
private async getImdsV2SessionToken(): Promise<string> {
const opts: GaxiosOptions = {
url: this.imdsV2SessionTokenUrl,
method: 'PUT',
responseType: 'text',
headers: {'x-aws-ec2-metadata-token-ttl-seconds': '300'},
};
const response = await this.transporter.request<string>(opts);
return response.data;
}

/**
* @param headers The headers to be used in the metadata request.
* @return A promise that resolves with the current AWS region.
*/
private async getAwsRegion(headers: Headers): Promise<string> {
// Priority order for region determination:
// AWS_REGION > AWS_DEFAULT_REGION > metadata server.
if (this.regionFromEnv) {
return this.regionFromEnv;
}
if (!this.regionUrl) {
throw new Error(
'Unable to determine AWS region due to missing ' +
'"options.credential_source.region_url"'
);
}
const opts: GaxiosOptions = {
url: this.regionUrl,
method: 'GET',
responseType: 'text',
headers: headers,
};
const response = await this.transporter.request<string>(opts);
// Remove last character. For example, if us-east-2b is returned,
// the region would be us-east-2.
return response.data.substr(0, response.data.length - 1);
}

/**
* @param headers The headers to be used in the metadata request.
* @return A promise that resolves with the assigned role to the current
* AWS VM. This is needed for calling the security-credentials endpoint.
*/
private async getAwsRoleName(headers: Headers): Promise<string> {
if (!this.securityCredentialsUrl) {
throw new Error(
'Unable to determine AWS role name due to missing ' +
'"options.credential_source.url"'
);
}
const opts: GaxiosOptions = {
url: this.securityCredentialsUrl,
method: 'GET',
responseType: 'text',
headers: headers,
};
const response = await this.transporter.request<string>(opts);
return response.data;
}

/**
* Retrieves the temporary AWS credentials by calling the security-credentials
* endpoint as specified in the `credential_source` object.
* @param roleName The role attached to the current VM.
* @param headers The headers to be used in the metadata request.
* @return A promise that resolves with the temporary AWS credentials
* needed for creating the GetCallerIdentity signed request.
*/
private async getAwsSecurityCredentials(
roleName: string,
headers: Headers
): Promise<AwsSecurityCredentialsResponse> {
const response =
await this.transporter.request<AwsSecurityCredentialsResponse>({
url: `${this.securityCredentialsUrl}/${roleName}`,
responseType: 'json',
headers: headers,
});
return response.data;
}

private get regionFromEnv(): string | null {
// The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION.
// Only one is required.
return (
process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION'] || null
);
}

private get securityCredentialsFromEnv(): AwsSecurityCredentials | null {
// Both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required.
if (
process.env['AWS_ACCESS_KEY_ID'] &&
process.env['AWS_SECRET_ACCESS_KEY']
) {
return {
accessKeyId: process.env['AWS_ACCESS_KEY_ID'],
secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'],
token: process.env['AWS_SESSION_TOKEN'],
};
}
return null;
}
}
25 changes: 25 additions & 0 deletions src/auth/baseexternalclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ export interface SharedExternalAccountClientOptions extends AuthClientOptions {
token_url: string;
}

/**
* Interface containing context about the requested external identity. This is
* passed on all requests from external account clients to external identity suppliers.
*/
export interface ExternalAccountSupplierContext {
/**
* The requested external account audience. For example:
aeitzman marked this conversation as resolved.
Show resolved Hide resolved
* "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID"
*/
audience: string;
/**
* The requested subject token type. Expected values include:
* * "urn:ietf:params:oauth:token-type:jwt"
* * "urn:ietf:params:aws:token-type:aws4_request"
* * "urn:ietf:params:oauth:token-type:saml2"
* * "urn:ietf:params:oauth:token-type:id_token"
*/
subjectTokenType: string;
}

/**
* Base external account credentials json interface.
*/
Expand Down Expand Up @@ -167,6 +187,7 @@ export abstract class BaseExternalAccountClient extends AuthClient {
* ```
*/
protected cloudResourceManagerURL: URL | string;
protected supplierContext: ExternalAccountSupplierContext;
/**
* Instantiate a BaseExternalAccountClient instance using the provided JSON
* object loaded from an external account credentials file.
Expand Down Expand Up @@ -254,6 +275,10 @@ export abstract class BaseExternalAccountClient extends AuthClient {
}

this.projectNumber = this.getProjectNumber(this.audience);
this.supplierContext = {
audience: this.audience,
subjectTokenType: this.subjectTokenType,
};
}

/** The service account email to be impersonated, if available. */
Expand Down
Loading
Loading