Skip to content

Commit 4ea7e8b

Browse files
aeitzmanlsiracdanielbankhead
authored
feat: refactor AWS and identity pool clients to use suppliers (#1776)
* feat: refactor aws and identity pool credentials to use suppliers * Apply suggestions from code review Co-authored-by: Leo <[email protected]> * Apply suggestions from code review Co-authored-by: Daniel Bankhead <[email protected]> * updating suppliers to use options objects * updating docs * moved transporter to context object and deprecated consts * fix imports --------- Co-authored-by: Leo <[email protected]> Co-authored-by: Daniel Bankhead <[email protected]>
1 parent 9a8d15f commit 4ea7e8b

6 files changed

+624
-331
lines changed

src/auth/awsclient.ts

+56-182
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15-
import {GaxiosOptions} from 'gaxios';
16-
1715
import {AwsRequestSigner, AwsSecurityCredentials} from './awsrequestsigner';
1816
import {
1917
BaseExternalAccountClient,
2018
BaseExternalAccountClientOptions,
19+
ExternalAccountSupplierContext,
2120
} from './baseexternalclient';
22-
import {Headers} from './oauth2client';
2321
import {AuthClientOptions} from './authclient';
22+
import {DefaultAwsSecurityCredentialsSupplier} from './defaultawssecuritycredentialssupplier';
2423

2524
/**
2625
* AWS credentials JSON interface. This is used for AWS workloads.
@@ -47,16 +46,34 @@ export interface AwsClientOptions extends BaseExternalAccountClientOptions {
4746
}
4847

4948
/**
50-
* Interface defining the AWS security-credentials endpoint response.
49+
* Supplier interface for AWS security credentials. This can be implemented to
50+
* return an AWS region and AWS security credentials. These credentials can
51+
* then be exchanged for a GCP token by an {@link AwsClient}.
5152
*/
52-
interface AwsSecurityCredentialsResponse {
53-
Code: string;
54-
LastUpdated: string;
55-
Type: string;
56-
AccessKeyId: string;
57-
SecretAccessKey: string;
58-
Token: string;
59-
Expiration: string;
53+
export interface AwsSecurityCredentialsSupplier {
54+
/**
55+
* Gets the active AWS region.
56+
* @param context {@link ExternalAccountSupplierContext} from the calling
57+
* {@link AwsClient}, contains the requested audience and subject token type
58+
* for the external account identity as well as the transport from the
59+
* calling client to use for requests.
60+
* @return A promise that resolves with the AWS region string.
61+
*/
62+
getAwsRegion: (context: ExternalAccountSupplierContext) => Promise<string>;
63+
64+
/**
65+
* Gets valid AWS security credentials for the requested external account
66+
* identity. Note that these are not cached by the calling {@link AwsClient},
67+
* so caching should be including in the implementation.
68+
* @param context {@link ExternalAccountSupplierContext} from the calling
69+
* {@link AwsClient}, contains the requested audience and subject token type
70+
* for the external account identity as well as the transport from the
71+
* calling client to use for requests.
72+
* @return A promise that resolves with the requested {@link AwsSecurityCredentials}.
73+
*/
74+
getAwsSecurityCredentials: (
75+
context: ExternalAccountSupplierContext
76+
) => Promise<AwsSecurityCredentials>;
6077
}
6178

6279
/**
@@ -66,14 +83,18 @@ interface AwsSecurityCredentialsResponse {
6683
*/
6784
export class AwsClient extends BaseExternalAccountClient {
6885
private readonly environmentId: string;
69-
private readonly regionUrl?: string;
70-
private readonly securityCredentialsUrl?: string;
86+
private readonly awsSecurityCredentialsSupplier: AwsSecurityCredentialsSupplier;
7187
private readonly regionalCredVerificationUrl: string;
72-
private readonly imdsV2SessionTokenUrl?: string;
7388
private awsRequestSigner: AwsRequestSigner | null;
7489
private region: string;
7590

91+
/**
92+
* @deprecated AWS client no validates the EC2 metadata address.
93+
**/
7694
static AWS_EC2_METADATA_IPV4_ADDRESS = '169.254.169.254';
95+
/**
96+
* @deprecated AWS client no validates the EC2 metadata address.
97+
**/
7798
static AWS_EC2_METADATA_IPV6_ADDRESS = 'fd00:ec2::254';
7899

79100
/**
@@ -95,14 +116,21 @@ export class AwsClient extends BaseExternalAccountClient {
95116
this.environmentId = options.credential_source.environment_id;
96117
// This is only required if the AWS region is not available in the
97118
// AWS_REGION or AWS_DEFAULT_REGION environment variables.
98-
this.regionUrl = options.credential_source.region_url;
119+
const regionUrl = options.credential_source.region_url;
99120
// This is only required if AWS security credentials are not available in
100121
// environment variables.
101-
this.securityCredentialsUrl = options.credential_source.url;
122+
const securityCredentialsUrl = options.credential_source.url;
123+
const imdsV2SessionTokenUrl =
124+
options.credential_source.imdsv2_session_token_url;
125+
this.awsSecurityCredentialsSupplier =
126+
new DefaultAwsSecurityCredentialsSupplier({
127+
regionUrl: regionUrl,
128+
securityCredentialsUrl: securityCredentialsUrl,
129+
imdsV2SessionTokenUrl: imdsV2SessionTokenUrl,
130+
});
131+
102132
this.regionalCredVerificationUrl =
103133
options.credential_source.regional_cred_verification_url;
104-
this.imdsV2SessionTokenUrl =
105-
options.credential_source.imdsv2_session_token_url;
106134
this.awsRequestSigner = null;
107135
this.region = '';
108136
this.credentialSourceType = 'aws';
@@ -124,68 +152,22 @@ export class AwsClient extends BaseExternalAccountClient {
124152

125153
/**
126154
* Triggered when an external subject token is needed to be exchanged for a
127-
* GCP access token via GCP STS endpoint.
128-
* This uses the `options.credential_source` object to figure out how
129-
* to retrieve the token using the current environment. In this case,
130-
* this uses a serialized AWS signed request to the STS GetCallerIdentity
131-
* endpoint.
132-
* The logic is summarized as:
133-
* 1. If imdsv2_session_token_url is provided in the credential source, then
134-
* fetch the aws session token and include it in the headers of the
135-
* metadata requests. This is a requirement for IDMSv2 but optional
136-
* for IDMSv1.
137-
* 2. Retrieve AWS region from availability-zone.
138-
* 3a. Check AWS credentials in environment variables. If not found, get
139-
* from security-credentials endpoint.
140-
* 3b. Get AWS credentials from security-credentials endpoint. In order
141-
* to retrieve this, the AWS role needs to be determined by calling
142-
* security-credentials endpoint without any argument. Then the
143-
* credentials can be retrieved via: security-credentials/role_name
144-
* 4. Generate the signed request to AWS STS GetCallerIdentity action.
145-
* 5. Inject x-goog-cloud-target-resource into header and serialize the
146-
* signed request. This will be the subject-token to pass to GCP STS.
155+
* GCP access token via GCP STS endpoint. This will call the
156+
* {@link AwsSecurityCredentialsSupplier} to retrieve an AWS region and AWS
157+
* Security Credentials, then use them to create a signed AWS STS request that
158+
* can be exchanged for a GCP access token.
147159
* @return A promise that resolves with the external subject token.
148160
*/
149161
async retrieveSubjectToken(): Promise<string> {
150162
// Initialize AWS request signer if not already initialized.
151163
if (!this.awsRequestSigner) {
152-
const metadataHeaders: Headers = {};
153-
// Only retrieve the IMDSv2 session token if both the security credentials and region are
154-
// not retrievable through the environment.
155-
// The credential config contains all the URLs by default but clients may be running this
156-
// where the metadata server is not available and returning the credentials through the environment.
157-
// Removing this check may break them.
158-
if (!this.regionFromEnv && this.imdsV2SessionTokenUrl) {
159-
metadataHeaders['x-aws-ec2-metadata-token'] =
160-
await this.getImdsV2SessionToken();
161-
}
162-
163-
this.region = await this.getAwsRegion(metadataHeaders);
164+
this.region = await this.awsSecurityCredentialsSupplier.getAwsRegion(
165+
this.supplierContext
166+
);
164167
this.awsRequestSigner = new AwsRequestSigner(async () => {
165-
// Check environment variables for permanent credentials first.
166-
// https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
167-
if (this.securityCredentialsFromEnv) {
168-
return this.securityCredentialsFromEnv;
169-
}
170-
if (this.imdsV2SessionTokenUrl) {
171-
metadataHeaders['x-aws-ec2-metadata-token'] =
172-
await this.getImdsV2SessionToken();
173-
}
174-
// Since the role on a VM can change, we don't need to cache it.
175-
const roleName = await this.getAwsRoleName(metadataHeaders);
176-
// Temporary credentials typically last for several hours.
177-
// Expiration is returned in response.
178-
// Consider future optimization of this logic to cache AWS tokens
179-
// until their natural expiration.
180-
const awsCreds = await this.getAwsSecurityCredentials(
181-
roleName,
182-
metadataHeaders
168+
return this.awsSecurityCredentialsSupplier.getAwsSecurityCredentials(
169+
this.supplierContext
183170
);
184-
return {
185-
accessKeyId: awsCreds.AccessKeyId,
186-
secretAccessKey: awsCreds.SecretAccessKey,
187-
token: awsCreds.Token,
188-
};
189171
}, this.region);
190172
}
191173

@@ -234,112 +216,4 @@ export class AwsClient extends BaseExternalAccountClient {
234216
})
235217
);
236218
}
237-
238-
/**
239-
* @return A promise that resolves with the IMDSv2 Session Token.
240-
*/
241-
private async getImdsV2SessionToken(): Promise<string> {
242-
const opts: GaxiosOptions = {
243-
url: this.imdsV2SessionTokenUrl,
244-
method: 'PUT',
245-
responseType: 'text',
246-
headers: {'x-aws-ec2-metadata-token-ttl-seconds': '300'},
247-
};
248-
const response = await this.transporter.request<string>(opts);
249-
return response.data;
250-
}
251-
252-
/**
253-
* @param headers The headers to be used in the metadata request.
254-
* @return A promise that resolves with the current AWS region.
255-
*/
256-
private async getAwsRegion(headers: Headers): Promise<string> {
257-
// Priority order for region determination:
258-
// AWS_REGION > AWS_DEFAULT_REGION > metadata server.
259-
if (this.regionFromEnv) {
260-
return this.regionFromEnv;
261-
}
262-
if (!this.regionUrl) {
263-
throw new Error(
264-
'Unable to determine AWS region due to missing ' +
265-
'"options.credential_source.region_url"'
266-
);
267-
}
268-
const opts: GaxiosOptions = {
269-
url: this.regionUrl,
270-
method: 'GET',
271-
responseType: 'text',
272-
headers: headers,
273-
};
274-
const response = await this.transporter.request<string>(opts);
275-
// Remove last character. For example, if us-east-2b is returned,
276-
// the region would be us-east-2.
277-
return response.data.substr(0, response.data.length - 1);
278-
}
279-
280-
/**
281-
* @param headers The headers to be used in the metadata request.
282-
* @return A promise that resolves with the assigned role to the current
283-
* AWS VM. This is needed for calling the security-credentials endpoint.
284-
*/
285-
private async getAwsRoleName(headers: Headers): Promise<string> {
286-
if (!this.securityCredentialsUrl) {
287-
throw new Error(
288-
'Unable to determine AWS role name due to missing ' +
289-
'"options.credential_source.url"'
290-
);
291-
}
292-
const opts: GaxiosOptions = {
293-
url: this.securityCredentialsUrl,
294-
method: 'GET',
295-
responseType: 'text',
296-
headers: headers,
297-
};
298-
const response = await this.transporter.request<string>(opts);
299-
return response.data;
300-
}
301-
302-
/**
303-
* Retrieves the temporary AWS credentials by calling the security-credentials
304-
* endpoint as specified in the `credential_source` object.
305-
* @param roleName The role attached to the current VM.
306-
* @param headers The headers to be used in the metadata request.
307-
* @return A promise that resolves with the temporary AWS credentials
308-
* needed for creating the GetCallerIdentity signed request.
309-
*/
310-
private async getAwsSecurityCredentials(
311-
roleName: string,
312-
headers: Headers
313-
): Promise<AwsSecurityCredentialsResponse> {
314-
const response =
315-
await this.transporter.request<AwsSecurityCredentialsResponse>({
316-
url: `${this.securityCredentialsUrl}/${roleName}`,
317-
responseType: 'json',
318-
headers: headers,
319-
});
320-
return response.data;
321-
}
322-
323-
private get regionFromEnv(): string | null {
324-
// The AWS region can be provided through AWS_REGION or AWS_DEFAULT_REGION.
325-
// Only one is required.
326-
return (
327-
process.env['AWS_REGION'] || process.env['AWS_DEFAULT_REGION'] || null
328-
);
329-
}
330-
331-
private get securityCredentialsFromEnv(): AwsSecurityCredentials | null {
332-
// Both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are required.
333-
if (
334-
process.env['AWS_ACCESS_KEY_ID'] &&
335-
process.env['AWS_SECRET_ACCESS_KEY']
336-
) {
337-
return {
338-
accessKeyId: process.env['AWS_ACCESS_KEY_ID'],
339-
secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'],
340-
token: process.env['AWS_SESSION_TOKEN'],
341-
};
342-
}
343-
return null;
344-
}
345219
}

src/auth/baseexternalclient.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
import {
16+
Gaxios,
1617
GaxiosError,
1718
GaxiosOptions,
1819
GaxiosPromise,
@@ -22,7 +23,7 @@ import * as stream from 'stream';
2223

2324
import {Credentials} from './credentials';
2425
import {AuthClient, AuthClientOptions} from './authclient';
25-
import {BodyResponseCallback} from '../transporters';
26+
import {BodyResponseCallback, Transporter} from '../transporters';
2627
import {GetAccessTokenResponse, Headers} from './oauth2client';
2728
import * as sts from './stscredentials';
2829
import {ClientAuthentication} from './oauth2common';
@@ -77,6 +78,31 @@ export interface SharedExternalAccountClientOptions extends AuthClientOptions {
7778
token_url: string;
7879
}
7980

81+
/**
82+
* Interface containing context about the requested external identity. This is
83+
* passed on all requests from external account clients to external identity suppliers.
84+
*/
85+
export interface ExternalAccountSupplierContext {
86+
/**
87+
* The requested external account audience. For example:
88+
* * "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID"
89+
* * "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID"
90+
*/
91+
audience: string;
92+
/**
93+
* The requested subject token type. Expected values include:
94+
* * "urn:ietf:params:oauth:token-type:jwt"
95+
* * "urn:ietf:params:aws:token-type:aws4_request"
96+
* * "urn:ietf:params:oauth:token-type:saml2"
97+
* * "urn:ietf:params:oauth:token-type:id_token"
98+
*/
99+
subjectTokenType: string;
100+
/** The {@link Gaxios} or {@link Transporter} instance from
101+
* the calling external account to use for requests.
102+
*/
103+
transporter: Transporter | Gaxios;
104+
}
105+
80106
/**
81107
* Base external account credentials json interface.
82108
*/
@@ -167,6 +193,7 @@ export abstract class BaseExternalAccountClient extends AuthClient {
167193
* ```
168194
*/
169195
protected cloudResourceManagerURL: URL | string;
196+
protected supplierContext: ExternalAccountSupplierContext;
170197
/**
171198
* Instantiate a BaseExternalAccountClient instance using the provided JSON
172199
* object loaded from an external account credentials file.
@@ -254,6 +281,11 @@ export abstract class BaseExternalAccountClient extends AuthClient {
254281
}
255282

256283
this.projectNumber = this.getProjectNumber(this.audience);
284+
this.supplierContext = {
285+
audience: this.audience,
286+
subjectTokenType: this.subjectTokenType,
287+
transporter: this.transporter,
288+
};
257289
}
258290

259291
/** The service account email to be impersonated, if available. */

0 commit comments

Comments
 (0)