diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index dc2e0eee..a189c659 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -12,15 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GaxiosOptions} from 'gaxios'; - import {AwsRequestSigner, AwsSecurityCredentials} from './awsrequestsigner'; import { BaseExternalAccountClient, BaseExternalAccountClientOptions, + ExternalAccountSupplierContext, } from './baseexternalclient'; -import {Headers} from './oauth2client'; import {AuthClientOptions} from './authclient'; +import {DefaultAwsSecurityCredentialsSupplier} from './defaultawssecuritycredentialssupplier'; /** * AWS credentials JSON interface. This is used for AWS workloads. @@ -47,16 +46,34 @@ 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 as well as the transport from the + * calling client to use for requests. + * @return A promise that resolves with the AWS region string. + */ + getAwsRegion: (context: ExternalAccountSupplierContext) => Promise; + + /** + * 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 as well as the transport from the + * calling client to use for requests. + * @return A promise that resolves with the requested {@link AwsSecurityCredentials}. + */ + getAwsSecurityCredentials: ( + context: ExternalAccountSupplierContext + ) => Promise; } /** @@ -66,14 +83,18 @@ 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; + /** + * @deprecated AWS client no validates the EC2 metadata address. + **/ static AWS_EC2_METADATA_IPV4_ADDRESS = '169.254.169.254'; + /** + * @deprecated AWS client no validates the EC2 metadata address. + **/ static AWS_EC2_METADATA_IPV6_ADDRESS = 'fd00:ec2::254'; /** @@ -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: regionUrl, + securityCredentialsUrl: securityCredentialsUrl, + imdsV2SessionTokenUrl: 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'; @@ -124,68 +152,22 @@ 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 { // 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.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 ); - return { - accessKeyId: awsCreds.AccessKeyId, - secretAccessKey: awsCreds.SecretAccessKey, - token: awsCreds.Token, - }; }, this.region); } @@ -234,112 +216,4 @@ export class AwsClient extends BaseExternalAccountClient { }) ); } - - /** - * @return A promise that resolves with the IMDSv2 Session Token. - */ - private async getImdsV2SessionToken(): Promise { - 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(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 { - // 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(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 { - 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(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 { - const response = - await this.transporter.request({ - 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; - } } diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 45ff17ff..52af5464 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -13,6 +13,7 @@ // limitations under the License. import { + Gaxios, GaxiosError, GaxiosOptions, GaxiosPromise, @@ -22,7 +23,7 @@ import * as stream from 'stream'; import {Credentials} from './credentials'; import {AuthClient, AuthClientOptions} from './authclient'; -import {BodyResponseCallback} from '../transporters'; +import {BodyResponseCallback, Transporter} from '../transporters'; import {GetAccessTokenResponse, Headers} from './oauth2client'; import * as sts from './stscredentials'; import {ClientAuthentication} from './oauth2common'; @@ -77,6 +78,31 @@ 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: + * * "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID" + * * "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/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; + /** The {@link Gaxios} or {@link Transporter} instance from + * the calling external account to use for requests. + */ + transporter: Transporter | Gaxios; +} + /** * Base external account credentials json interface. */ @@ -167,6 +193,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. @@ -254,6 +281,11 @@ export abstract class BaseExternalAccountClient extends AuthClient { } this.projectNumber = this.getProjectNumber(this.audience); + this.supplierContext = { + audience: this.audience, + subjectTokenType: this.subjectTokenType, + transporter: this.transporter, + }; } /** The service account email to be impersonated, if available. */ diff --git a/src/auth/defaultawssecuritycredentialssupplier.ts b/src/auth/defaultawssecuritycredentialssupplier.ts new file mode 100644 index 00000000..0d97bfd7 --- /dev/null +++ b/src/auth/defaultawssecuritycredentialssupplier.ts @@ -0,0 +1,260 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {ExternalAccountSupplierContext} from './baseexternalclient'; +import {Gaxios, GaxiosOptions} from 'gaxios'; +import {Transporter} from '../transporters'; +import {AwsSecurityCredentialsSupplier} from './awsclient'; +import {AwsSecurityCredentials} from './awsrequestsigner'; +import {Headers} from './oauth2client'; + +/** + * Interface defining the AWS security-credentials endpoint response. + */ +interface AwsSecurityCredentialsResponse { + Code: string; + LastUpdated: string; + Type: string; + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + Expiration: string; +} + +/** + * Interface defining the options used to build a {@link DefaultAwsSecurityCredentialsSupplier}. + */ +export interface DefaultAwsSecurityCredentialsSupplierOptions { + /** + * The URL to call to retrieve the active AWS region. + **/ + regionUrl?: string; + /** + * The URL to call to retrieve AWS security credentials. + **/ + securityCredentialsUrl?: string; + /** + ** The URL to call to retrieve the IMDSV2 session token. + **/ imdsV2SessionTokenUrl?: string; +} + +/** + * Internal AWS security credentials supplier implementation used by {@link AwsClient} + * when a credential source is provided instead of a user defined supplier. + * 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. + */ +export class DefaultAwsSecurityCredentialsSupplier + implements AwsSecurityCredentialsSupplier +{ + private readonly regionUrl?: string; + private readonly securityCredentialsUrl?: string; + private readonly imdsV2SessionTokenUrl?: string; + + /** + * Instantiates a new DefaultAwsSecurityCredentialsSupplier using information + * from the credential_source stored in the ADC file. + * @param opts The default aws security credentials supplier options object to + * build the supplier with. + */ + constructor(opts: DefaultAwsSecurityCredentialsSupplierOptions) { + this.regionUrl = opts.regionUrl; + this.securityCredentialsUrl = opts.securityCredentialsUrl; + this.imdsV2SessionTokenUrl = opts.imdsV2SessionTokenUrl; + } + + /** + * Returns the active AWS region. This first checks to see if the region + * is available as an environment variable. If it is not, then the supplier + * will call the region URL. + * @param context {@link ExternalAccountSupplierContext} from the calling + * {@link AwsClient}, contains the requested audience and subject token type + * for the external account identity. + * @return A promise that resolves with the AWS region string. + */ + async getAwsRegion(context: ExternalAccountSupplierContext): Promise { + // Priority order for region determination: + // AWS_REGION > AWS_DEFAULT_REGION > metadata server. + if (this.#regionFromEnv) { + return this.#regionFromEnv; + } + + const metadataHeaders: Headers = {}; + if (!this.#regionFromEnv && this.imdsV2SessionTokenUrl) { + metadataHeaders['x-aws-ec2-metadata-token'] = + await this.#getImdsV2SessionToken(context.transporter); + } + 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: metadataHeaders, + }; + const response = await context.transporter.request(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); + } + + /** + * Returns AWS security credentials. This first checks to see if the credentials + * is available as environment variables. If it is not, then the supplier + * will call the security credentials URL. + * @param context {@link ExternalAccountSupplierContext} from the calling + * {@link AwsClient}, contains the requested audience and subject token type + * for the external account identity. + * @return A promise that resolves with the AWS security credentials. + */ + async getAwsSecurityCredentials( + context: ExternalAccountSupplierContext + ): Promise { + // 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; + } + + const metadataHeaders: Headers = {}; + if (this.imdsV2SessionTokenUrl) { + metadataHeaders['x-aws-ec2-metadata-token'] = + await this.#getImdsV2SessionToken(context.transporter); + } + // Since the role on a VM can change, we don't need to cache it. + const roleName = await this.#getAwsRoleName( + metadataHeaders, + context.transporter + ); + // 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.#retrieveAwsSecurityCredentials( + roleName, + metadataHeaders, + context.transporter + ); + return { + accessKeyId: awsCreds.AccessKeyId, + secretAccessKey: awsCreds.SecretAccessKey, + token: awsCreds.Token, + }; + } + + /** + * @param transporter The transporter to use for requests. + * @return A promise that resolves with the IMDSv2 Session Token. + */ + async #getImdsV2SessionToken( + transporter: Transporter | Gaxios + ): Promise { + const opts: GaxiosOptions = { + url: this.imdsV2SessionTokenUrl, + method: 'PUT', + responseType: 'text', + headers: {'x-aws-ec2-metadata-token-ttl-seconds': '300'}, + }; + const response = await transporter.request(opts); + return response.data; + } + + /** + * @param headers The headers to be used in the metadata request. + * @param transporter The transporter to use for requests. + * @return A promise that resolves with the assigned role to the current + * AWS VM. This is needed for calling the security-credentials endpoint. + */ + async #getAwsRoleName( + headers: Headers, + transporter: Transporter | Gaxios + ): Promise { + 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 transporter.request(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. + * @param transporter The transporter to use for requests. + * @return A promise that resolves with the temporary AWS credentials + * needed for creating the GetCallerIdentity signed request. + */ + async #retrieveAwsSecurityCredentials( + roleName: string, + headers: Headers, + transporter: Transporter | Gaxios + ): Promise { + const response = await transporter.request({ + url: `${this.securityCredentialsUrl}/${roleName}`, + responseType: 'json', + headers: headers, + }); + return response.data; + } + + 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 + ); + } + + 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; + } +} diff --git a/src/auth/filesubjecttokensupplier.ts b/src/auth/filesubjecttokensupplier.ts new file mode 100644 index 00000000..8882980c --- /dev/null +++ b/src/auth/filesubjecttokensupplier.ts @@ -0,0 +1,114 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {ExternalAccountSupplierContext} from './baseexternalclient'; +import { + SubjectTokenFormatType, + SubjectTokenJsonResponse, + SubjectTokenSupplier, +} from './identitypoolclient'; +import {promisify} from 'util'; +import * as fs from 'fs'; + +// fs.readfile is undefined in browser karma tests causing +// `npm run browser-test` to fail as test.oauth2.ts imports this file via +// src/index.ts. +// Fallback to void function to avoid promisify throwing a TypeError. +const readFile = promisify(fs.readFile ?? (() => {})); +const realpath = promisify(fs.realpath ?? (() => {})); +const lstat = promisify(fs.lstat ?? (() => {})); + +/** + * Interface that defines options used to build a {@link FileSubjectTokenSupplier} + */ +export interface FileSubjectTokenSupplierOptions { + /** + * The file path where the external credential is located. + */ + filePath: string; + /** + * The token file or URL response type (JSON or text). + */ + formatType: SubjectTokenFormatType; + /** + * For JSON response types, this is the subject_token field name. For Azure, + * this is access_token. For text response types, this is ignored. + */ + subjectTokenFieldName?: string; +} + +/** + * Internal subject token supplier implementation used when a file location + * is configured in the credential configuration used to build an {@link IdentityPoolClient} + */ +export class FileSubjectTokenSupplier implements SubjectTokenSupplier { + private readonly filePath: string; + private readonly formatType: SubjectTokenFormatType; + private readonly subjectTokenFieldName?: string; + + /** + * Instantiates a new file based subject token supplier. + * @param opts The file subject token supplier options to build the supplier + * with. + */ + constructor(opts: FileSubjectTokenSupplierOptions) { + this.filePath = opts.filePath; + this.formatType = opts.formatType; + this.subjectTokenFieldName = opts.subjectTokenFieldName; + } + + /** + * Returns the subject token stored at the file specified in the constructor. + * @param context {@link ExternalAccountSupplierContext} from the calling + * {@link IdentityPoolClient}, contains the requested audience and subject + * token type for the external account identity. Not used. + */ + async getSubjectToken( + context: ExternalAccountSupplierContext + ): Promise { + // Make sure there is a file at the path. lstatSync will throw if there is + // nothing there. + let parsedFilePath = this.filePath; + try { + // Resolve path to actual file in case of symlink. Expect a thrown error + // if not resolvable. + parsedFilePath = await realpath(parsedFilePath); + + if (!(await lstat(parsedFilePath)).isFile()) { + throw new Error(); + } + } catch (err) { + if (err instanceof Error) { + err.message = `The file at ${parsedFilePath} does not exist, or it is not a file. ${err.message}`; + } + + throw err; + } + + let subjectToken: string | undefined; + const rawText = await readFile(parsedFilePath, {encoding: 'utf8'}); + if (this.formatType === 'text') { + subjectToken = rawText; + } else if (this.formatType === 'json' && this.subjectTokenFieldName) { + const json = JSON.parse(rawText) as SubjectTokenJsonResponse; + subjectToken = json[this.subjectTokenFieldName]; + } + if (!subjectToken) { + throw new Error( + 'Unable to parse the subject_token from the credential_source file' + ); + } + return subjectToken; + } +} diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 2ed66b11..145cd1f0 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -12,31 +12,41 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GaxiosOptions} from 'gaxios'; -import * as fs from 'fs'; -import {promisify} from 'util'; - import { BaseExternalAccountClient, BaseExternalAccountClientOptions, + ExternalAccountSupplierContext, } from './baseexternalclient'; import {AuthClientOptions} from './authclient'; import {SnakeToCamelObject, originalOrCamelOptions} from '../util'; +import {FileSubjectTokenSupplier} from './filesubjecttokensupplier'; +import {UrlSubjectTokenSupplier} from './urlsubjecttokensupplier'; -// fs.readfile is undefined in browser karma tests causing -// `npm run browser-test` to fail as test.oauth2.ts imports this file via -// src/index.ts. -// Fallback to void function to avoid promisify throwing a TypeError. -const readFile = promisify(fs.readFile ?? (() => {})); -const realpath = promisify(fs.realpath ?? (() => {})); -const lstat = promisify(fs.lstat ?? (() => {})); - -type SubjectTokenFormatType = 'json' | 'text'; +export type SubjectTokenFormatType = 'json' | 'text'; -interface SubjectTokenJsonResponse { +export interface SubjectTokenJsonResponse { [key: string]: string; } +/** + * Supplier interface for subject tokens. This can be implemented to + * return a subject token which can then be exchanged for a GCP token by an + * {@link IdentityPoolClient}. + */ +export interface SubjectTokenSupplier { + /** + * Gets a valid subject token for the requested external account identity. + * Note that these are not cached by the calling {@link IdentityPoolClient}, + * so caching should be including in the implementation. + * @param context {@link ExternalAccountSupplierContext} from the calling + * {@link IdentityPoolClient}, contains the requested audience and subject token type + * for the external account identity as well as the transport from the + * calling client to use for requests. + * @return A promise that resolves with the requested subject token string. + */ + getSubjectToken: (context: ExternalAccountSupplierContext) => Promise; +} + /** * Url-sourced/file-sourced credentials json interface. * This is used for K8s and Azure workloads. @@ -61,11 +71,7 @@ export interface IdentityPoolClientOptions * used for K8s and Azure workloads. */ export class IdentityPoolClient extends BaseExternalAccountClient { - private readonly file?: string; - private readonly url?: string; - private readonly headers?: {[key: string]: string}; - private readonly formatType: SubjectTokenFormatType; - private readonly formatSubjectTokenFieldName?: string; + private readonly subjectTokenSupplier: SubjectTokenSupplier; /** * Instantiate an IdentityPoolClient instance using the provided JSON @@ -93,157 +99,61 @@ export class IdentityPoolClient extends BaseExternalAccountClient { const credentialSource = opts.get('credential_source'); const credentialSourceOpts = originalOrCamelOptions(credentialSource); - this.file = credentialSourceOpts.get('file'); - this.url = credentialSourceOpts.get('url'); - this.headers = credentialSourceOpts.get('headers'); - if (this.file && this.url) { - throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.' - ); - } else if (this.file && !this.url) { - this.credentialSourceType = 'file'; - } else if (!this.file && this.url) { - this.credentialSourceType = 'url'; - } else { - throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.' - ); - } - const formatOpts = originalOrCamelOptions( credentialSourceOpts.get('format') ); // Text is the default format type. - this.formatType = formatOpts.get('type') || 'text'; - this.formatSubjectTokenFieldName = formatOpts.get( + const formatType = formatOpts.get('type') || 'text'; + const formatSubjectTokenFieldName = formatOpts.get( 'subject_token_field_name' ); - if (this.formatType !== 'json' && this.formatType !== 'text') { - throw new Error(`Invalid credential_source format "${this.formatType}"`); + if (formatType !== 'json' && formatType !== 'text') { + throw new Error(`Invalid credential_source format "${formatType}"`); } - if (this.formatType === 'json' && !this.formatSubjectTokenFieldName) { + if (formatType === 'json' && !formatSubjectTokenFieldName) { throw new Error( 'Missing subject_token_field_name for JSON credential_source format' ); } - } - /** - * Triggered when a 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 either retrieves the local credential from a file location (k8s - * workload) or by sending a GET request to a local metadata server (Azure - * workloads). - * @return A promise that resolves with the external subject token. - */ - async retrieveSubjectToken(): Promise { - if (this.file) { - return await this.getTokenFromFile( - this.file!, - this.formatType, - this.formatSubjectTokenFieldName + const file = credentialSourceOpts.get('file'); + const url = credentialSourceOpts.get('url'); + const headers = credentialSourceOpts.get('headers'); + if (file && url) { + throw new Error( + 'No valid Identity Pool "credential_source" provided, must be either file or url.' ); - } - return await this.getTokenFromUrl( - this.url!, - this.formatType, - this.formatSubjectTokenFieldName, - this.headers - ); - } - - /** - * Looks up the external subject token in the file path provided and - * resolves with that token. - * @param file The file path where the external credential is located. - * @param formatType The token file or URL response type (JSON or text). - * @param formatSubjectTokenFieldName For JSON response types, this is the - * subject_token field name. For Azure, this is access_token. For text - * response types, this is ignored. - * @return A promise that resolves with the external subject token. - */ - private async getTokenFromFile( - filePath: string, - formatType: SubjectTokenFormatType, - formatSubjectTokenFieldName?: string - ): Promise { - // Make sure there is a file at the path. lstatSync will throw if there is - // nothing there. - try { - // Resolve path to actual file in case of symlink. Expect a thrown error - // if not resolvable. - filePath = await realpath(filePath); - - if (!(await lstat(filePath)).isFile()) { - throw new Error(); - } - } catch (err) { - if (err instanceof Error) { - err.message = `The file at ${filePath} does not exist, or it is not a file. ${err.message}`; - } - - throw err; - } - - let subjectToken: string | undefined; - const rawText = await readFile(filePath, {encoding: 'utf8'}); - if (formatType === 'text') { - subjectToken = rawText; - } else if (formatType === 'json' && formatSubjectTokenFieldName) { - const json = JSON.parse(rawText) as SubjectTokenJsonResponse; - subjectToken = json[formatSubjectTokenFieldName]; - } - if (!subjectToken) { + } else if (file && !url) { + this.credentialSourceType = 'file'; + this.subjectTokenSupplier = new FileSubjectTokenSupplier({ + filePath: file, + formatType: formatType, + subjectTokenFieldName: formatSubjectTokenFieldName, + }); + } else if (!file && url) { + this.credentialSourceType = 'url'; + this.subjectTokenSupplier = new UrlSubjectTokenSupplier({ + url: url, + formatType: formatType, + subjectTokenFieldName: formatSubjectTokenFieldName, + headers: headers, + }); + } else { throw new Error( - 'Unable to parse the subject_token from the credential_source file' + 'No valid Identity Pool "credential_source" provided, must be either file or url.' ); } - return subjectToken; } /** - * Sends a GET request to the URL provided and resolves with the returned - * external subject token. - * @param url The URL to call to retrieve the subject token. This is typically - * a local metadata server. - * @param formatType The token file or URL response type (JSON or text). - * @param formatSubjectTokenFieldName For JSON response types, this is the - * subject_token field name. For Azure, this is access_token. For text - * response types, this is ignored. - * @param headers The optional additional headers to send with the request to - * the metadata server url. + * Triggered when a external subject token is needed to be exchanged for a GCP + * access token via GCP STS endpoint. Gets a subject token by calling + * the configured {@link SubjectTokenSupplier} * @return A promise that resolves with the external subject token. */ - private async getTokenFromUrl( - url: string, - formatType: SubjectTokenFormatType, - formatSubjectTokenFieldName?: string, - headers?: {[key: string]: string} - ): Promise { - const opts: GaxiosOptions = { - url, - method: 'GET', - headers, - responseType: formatType, - }; - let subjectToken: string | undefined; - if (formatType === 'text') { - const response = await this.transporter.request(opts); - subjectToken = response.data; - } else if (formatType === 'json' && formatSubjectTokenFieldName) { - const response = - await this.transporter.request(opts); - subjectToken = response.data[formatSubjectTokenFieldName]; - } - if (!subjectToken) { - throw new Error( - 'Unable to parse the subject_token from the credential_source URL' - ); - } - return subjectToken; + async retrieveSubjectToken(): Promise { + return this.subjectTokenSupplier.getSubjectToken(this.supplierContext); } } diff --git a/src/auth/urlsubjecttokensupplier.ts b/src/auth/urlsubjecttokensupplier.ts new file mode 100644 index 00000000..abd3ac61 --- /dev/null +++ b/src/auth/urlsubjecttokensupplier.ts @@ -0,0 +1,103 @@ +// Copyright 2024 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {ExternalAccountSupplierContext} from './baseexternalclient'; +import {GaxiosOptions} from 'gaxios'; +import { + SubjectTokenFormatType, + SubjectTokenJsonResponse, + SubjectTokenSupplier, +} from './identitypoolclient'; + +/** + * Interface that defines options used to build a {@link UrlSubjectTokenSupplier} + */ +export interface UrlSubjectTokenSupplierOptions { + /** + * The URL to call to retrieve the subject token. This is typically a local + * metadata server. + */ + url: string; + /** + * The token file or URL response type (JSON or text). + */ + formatType: SubjectTokenFormatType; + /** + * For JSON response types, this is the subject_token field name. For Azure, + * this is access_token. For text response types, this is ignored. + */ + subjectTokenFieldName?: string; + /** + * The optional additional headers to send with the request to the metadata + * server url. + */ + headers?: {[key: string]: string}; +} + +/** + * Internal subject token supplier implementation used when a URL + * is configured in the credential configuration used to build an {@link IdentityPoolClient} + */ +export class UrlSubjectTokenSupplier implements SubjectTokenSupplier { + private readonly url: string; + private readonly headers?: {[key: string]: string}; + private readonly formatType: SubjectTokenFormatType; + private readonly subjectTokenFieldName?: string; + + /** + * Instantiates a URL subject token supplier. + * @param opts The URL subject token supplier options to build the supplier with. + */ + constructor(opts: UrlSubjectTokenSupplierOptions) { + this.url = opts.url; + this.formatType = opts.formatType; + this.subjectTokenFieldName = opts.subjectTokenFieldName; + this.headers = opts.headers; + } + + /** + * Sends a GET request to the URL provided in the constructor and resolves + * with the returned external subject token. + * @param context {@link ExternalAccountSupplierContext} from the calling + * {@link IdentityPoolClient}, contains the requested audience and subject + * token type for the external account identity. Not used. + */ + async getSubjectToken( + context: ExternalAccountSupplierContext + ): Promise { + const url = this.url; + const headers = this.headers; + const opts: GaxiosOptions = { + url, + method: 'GET', + headers, + responseType: this.formatType, + }; + let subjectToken: string | undefined; + if (this.formatType === 'text') { + const response = await context.transporter.request(opts); + subjectToken = response.data; + } else if (this.formatType === 'json' && this.subjectTokenFieldName) { + const response = + await context.transporter.request(opts); + subjectToken = response.data[this.subjectTokenFieldName]; + } + if (!subjectToken) { + throw new Error( + 'Unable to parse the subject_token from the credential_source URL' + ); + } + return subjectToken; + } +}