From 4ea7e8bdc45203d78c8ec147719a9f49523dd30e Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Wed, 27 Mar 2024 11:23:28 -0700 Subject: [PATCH 1/5] 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 <39062083+lsirac@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Daniel Bankhead * updating suppliers to use options objects * updating docs * moved transporter to context object and deprecated consts * fix imports --------- Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> Co-authored-by: Daniel Bankhead --- src/auth/awsclient.ts | 238 ++++------------ src/auth/baseexternalclient.ts | 34 ++- .../defaultawssecuritycredentialssupplier.ts | 260 ++++++++++++++++++ src/auth/filesubjecttokensupplier.ts | 114 ++++++++ src/auth/identitypoolclient.ts | 206 ++++---------- src/auth/urlsubjecttokensupplier.ts | 103 +++++++ 6 files changed, 624 insertions(+), 331 deletions(-) create mode 100644 src/auth/defaultawssecuritycredentialssupplier.ts create mode 100644 src/auth/filesubjecttokensupplier.ts create mode 100644 src/auth/urlsubjecttokensupplier.ts 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; + } +} From 468367d7b31fa1c95f1ca6072cd54b9e59bf9f48 Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Wed, 3 Apr 2024 15:45:03 -0700 Subject: [PATCH 2/5] feat: adds support for creating AWS and Identity Pool credentials with custom suppliers. (#1783) * feat: adds support for users to build credentials with custom suppliers Also adds default values to make it easier to instantiate credentials in code. * Apply suggestions from code review Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> * responding to review comments --------- Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> --- src/auth/awsclient.ts | 80 +++-- src/auth/baseexternalclient.ts | 12 +- .../externalAccountAuthorizedUserClient.ts | 11 +- src/auth/identitypoolclient.ts | 101 ++++--- test/test.awsclient.ts | 273 +++++++++++++++++- test/test.baseexternalclient.ts | 64 ++++ ...est.externalaccountauthorizeduserclient.ts | 46 +++ test/test.identitypoolclient.ts | 272 ++++++++++++++++- 8 files changed, 780 insertions(+), 79 deletions(-) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index a189c659..f55824d3 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -20,12 +20,13 @@ import { } from './baseexternalclient'; import {AuthClientOptions} from './authclient'; import {DefaultAwsSecurityCredentialsSupplier} from './defaultawssecuritycredentialssupplier'; +import {originalOrCamelOptions, SnakeToCamelObject} from '../util'; /** * AWS credentials JSON interface. This is used for AWS workloads. */ export interface AwsClientOptions extends BaseExternalAccountClientOptions { - credential_source: { + credential_source?: { environment_id: string; // Region can also be determined from the AWS_REGION or AWS_DEFAULT_REGION // environment variables. @@ -43,6 +44,7 @@ export interface AwsClientOptions extends BaseExternalAccountClientOptions { // The session token is required for IMDSv2 but optional for IMDSv1 imdsv2_session_token_url?: string; }; + aws_security_credentials_supplier?: AwsSecurityCredentialsSupplier; } /** @@ -82,12 +84,15 @@ export interface AwsSecurityCredentialsSupplier { * GCP access token. */ export class AwsClient extends BaseExternalAccountClient { - private readonly environmentId: string; + private readonly environmentId?: string; private readonly awsSecurityCredentialsSupplier: AwsSecurityCredentialsSupplier; private readonly regionalCredVerificationUrl: string; private awsRequestSigner: AwsRequestSigner | null; private region: string; + static #DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL = + 'https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15'; + /** * @deprecated AWS client no validates the EC2 metadata address. **/ @@ -109,34 +114,61 @@ export class AwsClient extends BaseExternalAccountClient { * on 401/403 API request errors. */ constructor( - options: AwsClientOptions, + options: AwsClientOptions | SnakeToCamelObject, additionalOptions?: AuthClientOptions ) { super(options, additionalOptions); - 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. - const regionUrl = options.credential_source.region_url; - // This is only required if AWS security credentials are not available in - // environment variables. - 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, - }); + const opts = originalOrCamelOptions(options as AwsClientOptions); + const credentialSource = opts.get('credential_source'); + const awsSecurityCredentialsSupplier = opts.get( + 'aws_security_credentials_supplier' + ); + // Validate credential sourcing configuration. + if (!credentialSource && !awsSecurityCredentialsSupplier) { + throw new Error( + 'A credential source or AWS security credentials supplier must be specified.' + ); + } + if (credentialSource && awsSecurityCredentialsSupplier) { + throw new Error( + 'Only one of credential source or AWS security credentials supplier can be specified.' + ); + } + + if (awsSecurityCredentialsSupplier) { + this.awsSecurityCredentialsSupplier = awsSecurityCredentialsSupplier; + this.regionalCredVerificationUrl = + AwsClient.#DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL; + this.credentialSourceType = 'programmatic'; + } else { + const credentialSourceOpts = originalOrCamelOptions(credentialSource); + this.environmentId = credentialSourceOpts.get('environment_id'); + // This is only required if the AWS region is not available in the + // AWS_REGION or AWS_DEFAULT_REGION environment variables. + const regionUrl = credentialSourceOpts.get('region_url'); + // This is only required if AWS security credentials are not available in + // environment variables. + const securityCredentialsUrl = credentialSourceOpts.get('url'); + const imdsV2SessionTokenUrl = credentialSourceOpts.get( + 'imdsv2_session_token_url' + ); + this.awsSecurityCredentialsSupplier = + new DefaultAwsSecurityCredentialsSupplier({ + regionUrl: regionUrl, + securityCredentialsUrl: securityCredentialsUrl, + imdsV2SessionTokenUrl: imdsV2SessionTokenUrl, + }); - this.regionalCredVerificationUrl = - options.credential_source.regional_cred_verification_url; + this.regionalCredVerificationUrl = credentialSourceOpts.get( + 'regional_cred_verification_url' + ); + this.credentialSourceType = 'aws'; + + // Data validators. + this.validateEnvironmentId(); + } this.awsRequestSigner = null; this.region = ''; - this.credentialSourceType = 'aws'; - - // Data validators. - this.validateEnvironmentId(); } private validateEnvironmentId() { diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 52af5464..c7b81710 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -64,6 +64,7 @@ export const CLOUD_RESOURCE_MANAGER = /** The workforce audience pattern. */ const WORKFORCE_AUDIENCE_PATTERN = '//iam\\.googleapis\\.com/locations/[^/]+/workforcePools/[^/]+/providers/.+'; +const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/token'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require('../../../package.json'); @@ -75,7 +76,7 @@ export {DEFAULT_UNIVERSE} from './authclient'; export interface SharedExternalAccountClientOptions extends AuthClientOptions { audience: string; - token_url: string; + token_url?: string; } /** @@ -108,7 +109,7 @@ export interface ExternalAccountSupplierContext { */ export interface BaseExternalAccountClientOptions extends SharedExternalAccountClientOptions { - type: string; + type?: string; subject_token_type: string; service_account_impersonation_url?: string; service_account_impersonation?: { @@ -217,7 +218,8 @@ export abstract class BaseExternalAccountClient extends AuthClient { options as BaseExternalAccountClientOptions ); - if (opts.get('type') !== EXTERNAL_ACCOUNT_TYPE) { + const type = opts.get('type'); + if (type && type !== EXTERNAL_ACCOUNT_TYPE) { throw new Error( `Expected "${EXTERNAL_ACCOUNT_TYPE}" type but ` + `received "${options.type}"` @@ -226,7 +228,9 @@ export abstract class BaseExternalAccountClient extends AuthClient { const clientId = opts.get('client_id'); const clientSecret = opts.get('client_secret'); - const tokenUrl = opts.get('token_url'); + const tokenUrl = + opts.get('token_url') ?? + DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain); const subjectTokenType = opts.get('subject_token_type'); const workforcePoolUserProject = opts.get('workforce_pool_user_project'); const serviceAccountImpersonationUrl = opts.get( diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index c9534440..734005c0 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -39,6 +39,7 @@ import { */ export const EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = 'external_account_authorized_user'; +const DEFAULT_TOKEN_URL = 'https://sts.{universeDomain}/v1/oauthtoken'; /** * External Account Authorized User Credentials JSON interface. @@ -171,6 +172,9 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { additionalOptions?: AuthClientOptions ) { super({...options, ...additionalOptions}); + if (options.universe_domain) { + this.universeDomain = options.universe_domain; + } this.refreshToken = options.refresh_token; const clientAuth = { confidentialClientType: 'basic', @@ -179,7 +183,8 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { } as ClientAuthentication; this.externalAccountAuthorizedUserHandler = new ExternalAccountAuthorizedUserHandler( - options.token_url, + options.token_url ?? + DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain), this.transporter, clientAuth ); @@ -197,10 +202,6 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { .eagerRefreshThresholdMillis as number; } this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; - - if (options.universe_domain) { - this.universeDomain = options.universe_domain; - } } async getAccessToken(): Promise<{ diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 145cd1f0..54f58fcc 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -53,7 +53,7 @@ export interface SubjectTokenSupplier { */ export interface IdentityPoolClientOptions extends BaseExternalAccountClientOptions { - credential_source: { + credential_source?: { file?: string; url?: string; headers?: { @@ -64,6 +64,7 @@ export interface IdentityPoolClientOptions subject_token_field_name?: string; }; }; + subject_token_supplier?: SubjectTokenSupplier; } /** @@ -97,53 +98,71 @@ export class IdentityPoolClient extends BaseExternalAccountClient { const opts = originalOrCamelOptions(options as IdentityPoolClientOptions); const credentialSource = opts.get('credential_source'); - const credentialSourceOpts = originalOrCamelOptions(credentialSource); - - const formatOpts = originalOrCamelOptions( - credentialSourceOpts.get('format') - ); - - // Text is the default format type. - const formatType = formatOpts.get('type') || 'text'; - const formatSubjectTokenFieldName = formatOpts.get( - 'subject_token_field_name' - ); - - if (formatType !== 'json' && formatType !== 'text') { - throw new Error(`Invalid credential_source format "${formatType}"`); - } - if (formatType === 'json' && !formatSubjectTokenFieldName) { + const subjectTokenSupplier = opts.get('subject_token_supplier'); + // Validate credential sourcing configuration. + if (!credentialSource && !subjectTokenSupplier) { throw new Error( - 'Missing subject_token_field_name for JSON credential_source format' + 'A credential source or subject token supplier must be specified.' ); } - - const file = credentialSourceOpts.get('file'); - const url = credentialSourceOpts.get('url'); - const headers = credentialSourceOpts.get('headers'); - if (file && url) { + if (credentialSource && subjectTokenSupplier) { throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.' + 'Only one of credential source or subject token supplier can be specified.' ); - } 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, - }); + } + + if (subjectTokenSupplier) { + this.subjectTokenSupplier = subjectTokenSupplier; + this.credentialSourceType = 'programmatic'; } else { - throw new Error( - 'No valid Identity Pool "credential_source" provided, must be either file or url.' + const credentialSourceOpts = originalOrCamelOptions(credentialSource); + + const formatOpts = originalOrCamelOptions( + credentialSourceOpts.get('format') + ); + + // Text is the default format type. + const formatType = formatOpts.get('type') || 'text'; + const formatSubjectTokenFieldName = formatOpts.get( + 'subject_token_field_name' ); + + if (formatType !== 'json' && formatType !== 'text') { + throw new Error(`Invalid credential_source format "${formatType}"`); + } + if (formatType === 'json' && !formatSubjectTokenFieldName) { + throw new Error( + 'Missing subject_token_field_name for JSON credential_source format' + ); + } + + 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.' + ); + } 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( + 'No valid Identity Pool "credential_source" provided, must be either file or url.' + ); + } } } diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index 800f670b..c83a2745 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -16,9 +16,12 @@ import * as assert from 'assert'; import {describe, it, afterEach, beforeEach} from 'mocha'; import * as nock from 'nock'; import * as sinon from 'sinon'; -import {AwsClient} from '../src/auth/awsclient'; +import {AwsClient, AwsSecurityCredentialsSupplier} from '../src/auth/awsclient'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; -import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import { + BaseExternalAccountClient, + ExternalAccountSupplierContext, +} from '../src/auth/baseexternalclient'; import { assertGaxiosResponsePresent, getAudience, @@ -28,6 +31,7 @@ import { mockStsTokenExchange, getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; +import {AwsSecurityCredentials} from '../src/auth/awsrequestsigner'; nock.disableNetConnect(); @@ -265,6 +269,36 @@ describe('AwsClient', () => { assert.throws(() => new AwsClient(invalidOptions), expectedError); }); + it('should throw when both a credential source and supplier are provided', () => { + const expectedError = new Error( + 'Only one of credential source or AWS security credentials supplier can be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: awsCredentialSource, + aws_security_credentials_supplier: new TestAwsSupplier({}), + }; + + assert.throws(() => new AwsClient(invalidOptions), expectedError); + }); + + it('should throw when neither a credential source or supplier are provided', () => { + const expectedError = new Error( + 'A credential source or AWS security credentials supplier must be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + }; + + assert.throws(() => new AwsClient(invalidOptions), expectedError); + }); + it('should not throw when valid AWS options are provided', () => { assert.doesNotThrow(() => { return new AwsClient(awsOptions); @@ -1042,4 +1076,239 @@ describe('AwsClient', () => { }); }); }); + + describe('for custom supplier retrieved tokens', () => { + describe('retrieveSubjectToken()', () => { + it('should resolve on success for permanent creds', async () => { + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + }); + + it('should resolve on success for temporary creds', async () => { + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + token: token, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + }); + + it('should reject when getAwsRegion() throws an error', async () => { + const expectedError = new Error('expected error message'); + const supplier = new TestAwsSupplier({ + regionError: expectedError, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should reject when getAwsSecurityCredentials() throws an error', async () => { + const expectedError = new Error('expected error message'); + const supplier = new TestAwsSupplier({ + region: awsRegion, + credentialsError: expectedError, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve on retrieveSubjectToken success', async () => { + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: expectedSubjectTokenNoToken, + subject_token_type: + 'urn:ietf:params:aws:token-type:aws4_request', + }, + }, + ]) + ); + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + + it('should reject on retrieveSubjectToken error', async () => { + const expectedError = new Error('expected error message'); + const supplier = new TestAwsSupplier({ + region: awsRegion, + credentialsError: expectedError, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + + await assert.rejects(client.getAccessToken(), expectedError); + }); + + it('should set x-goog-api-client header correctly', async () => { + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange( + [ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: expectedSubjectTokenNoToken, + subject_token_type: + 'urn:ietf:params:aws:token-type:aws4_request', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'programmatic', + false, + false + ), + } + ) + ); + const supplier = new TestAwsSupplier({ + credentials: { + accessKeyId: accessKeyId, + secretAccessKey: secretAccessKey, + }, + region: awsRegion, + }); + const options = { + aws_security_credentials_supplier: supplier, + audience: audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + }; + + const client = new AwsClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + }); + }); }); + +interface TestAwsSupplierOptions { + credentials?: AwsSecurityCredentials; + region?: string; + credentialsError?: Error; + regionError?: Error; +} + +class TestAwsSupplier implements AwsSecurityCredentialsSupplier { + private readonly credentials?: AwsSecurityCredentials; + private readonly region?: string; + private readonly credentialsError?: Error; + private readonly regionError?: Error; + + constructor(options: TestAwsSupplierOptions) { + this.credentials = options.credentials; + this.region = options.region; + this.credentialsError = options.credentialsError; + this.regionError = options.regionError; + } + + async getAwsRegion(context: ExternalAccountSupplierContext): Promise { + if (this.regionError) { + throw this.regionError; + } else { + return this.region ?? ''; + } + } + + async getAwsSecurityCredentials( + context: ExternalAccountSupplierContext + ): Promise { + if (this.credentialsError) { + throw this.credentialsError; + } else { + return this.credentials ?? {accessKeyId: '', secretAccessKey: ''}; + } + } +} diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 96ea57ce..46b80359 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -82,6 +82,14 @@ describe('BaseExternalAccountClient', () => { file: '/var/run/secrets/goog.id/token', }, }; + const externalAccountOptionsNoUrl = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + credential_source: { + file: '/var/run/secrets/goog.id/token', + }, + }; const externalAccountOptionsWithCreds = { type: 'external_account', audience, @@ -262,6 +270,62 @@ describe('BaseExternalAccountClient', () => { refreshOptions.eagerRefreshThresholdMillis ); }); + + it('should set default token url', async () => { + const client = new TestExternalAccountClient(externalAccountOptionsNoUrl); + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + await client.getAccessToken(); + + scope.done(); + }); + + it('should set universe domain on default token url', async() => { + const options: BaseExternalAccountClientOptions = { + ...externalAccountOptionsNoUrl, + universe_domain: 'test.com', + }; + + const client = new TestExternalAccountClient(options); + + const scope = mockStsTokenExchange( + [ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ], + {}, + 'https://sts.test.com' + ); + + await client.getAccessToken(); + + scope.done(); + }); }); describe('projectNumber', () => { diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts index c97e4b03..d106c4d6 100644 --- a/test/test.externalaccountauthorizeduserclient.ts +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -95,6 +95,14 @@ describe('ExternalAccountAuthorizedUserClient', () => { token_url: TOKEN_REFRESH_URL, token_info_url: TOKEN_INFO_URL, } as ExternalAccountAuthorizedUserClientOptions; + const externalAccountAuthorizedUserCredentialOptionsNoToken = { + type: EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, + audience: audience, + client_id: 'clientId', + client_secret: 'clientSecret', + refresh_token: 'refreshToken', + token_info_url: TOKEN_INFO_URL, + } as ExternalAccountAuthorizedUserClientOptions; const successfulRefreshResponse = { access_token: 'newAccessToken', refresh_token: 'newRefreshToken', @@ -133,6 +141,44 @@ describe('ExternalAccountAuthorizedUserClient', () => { assert(client.eagerRefreshThresholdMillis === EXPIRATION_TIME_OFFSET); }); + it('should set default token url', async () => { + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptionsNoToken + ); + await client.getAccessToken(); + scope.done(); + }); + + it('should set universe domain token url', async () => { + const scope = mockStsTokenRefresh('https://sts.test.com', REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + const client = new ExternalAccountAuthorizedUserClient({ + ...externalAccountAuthorizedUserCredentialOptionsNoToken, + universe_domain: 'test.com', + }); + await client.getAccessToken(); + scope.done(); + }); + it('should set custom RefreshOptions', () => { const refreshOptions = { eagerRefreshThresholdMillis: 5000, diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts index 1faa5bdd..ac9317c7 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -20,9 +20,13 @@ import {createCrypto} from '../src/crypto/crypto'; import { IdentityPoolClient, IdentityPoolClientOptions, + SubjectTokenSupplier, } from '../src/auth/identitypoolclient'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; -import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import { + BaseExternalAccountClient, + ExternalAccountSupplierContext, +} from '../src/auth/baseexternalclient'; import { assertGaxiosResponsePresent, getAudience, @@ -302,6 +306,42 @@ describe('IdentityPoolClient', () => { } ); + it('should throw when neither a credential source or a supplier is provided', () => { + const expectedError = new Error( + 'A credential source or subject token supplier must be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + + it('should throw when both a credential source and a supplier is provided', () => { + const expectedError = new Error( + 'Only one of credential source or subject token supplier can be specified.' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: {}, + subject_token_supplier: new TestSubjectTokenSupplier({}), + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + it('should not throw when valid file-sourced options are provided', () => { assert.doesNotThrow(() => { return new IdentityPoolClient(fileSourcedOptions); @@ -314,10 +354,21 @@ describe('IdentityPoolClient', () => { }); }); + it('should not throw when subject token supplier is provided', () => { + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({}), + }; + assert.doesNotThrow(() => { + return new IdentityPoolClient(options); + }); + }); + it('should not throw on headerless url-sourced options', () => { const urlSourcedOptionsNoHeaders = Object.assign({}, urlSourcedOptions); urlSourcedOptionsNoHeaders.credential_source = { - url: urlSourcedOptions.credential_source.url, + url: urlSourcedOptions.credential_source?.url, }; assert.doesNotThrow(() => { return new IdentityPoolClient(urlSourcedOptionsNoHeaders); @@ -865,7 +916,7 @@ describe('IdentityPoolClient', () => { // Create options without headers. const urlSourcedOptionsNoHeaders = Object.assign({}, urlSourcedOptions); urlSourcedOptionsNoHeaders.credential_source = { - url: urlSourcedOptions.credential_source.url, + url: urlSourcedOptions.credential_source?.url, }; const externalSubjectToken = 'SUBJECT_TOKEN_1'; const scope = nock(metadataBaseUrl) @@ -1150,4 +1201,219 @@ describe('IdentityPoolClient', () => { }); }); }); + + describe('for supplier-sourced subject tokens', () => { + describe('retrieveSubjectToken()', () => { + it('should resolve when the subject token is returned', async () => { + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: 'TestTokenValue', + }), + }; + const client = new IdentityPoolClient(options); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, 'TestTokenValue'); + }); + + it('should return when the an error is returned', async () => { + const expectedError = new Error('Test error from supplier.'); + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + error: expectedError, + }), + }; + const client = new IdentityPoolClient(options); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve on retrieveSubjectToken success', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: externalSubjectToken, + }), + }; + const client = new IdentityPoolClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + + it('should handle service account access token', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]), + mockGenerateAccessToken({ + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }) + ); + + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + service_account_impersonation_url: + getServiceAccountImpersonationUrl(), + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: externalSubjectToken, + }), + }; + const client = new IdentityPoolClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: saSuccessResponse.accessToken, + }); + scopes.forEach(scope => scope.done()); + }); + + it('should reject with retrieveSubjectToken error', async () => { + const expectedError = new Error('Test error from supplier.'); + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + error: expectedError, + }), + }; + const client = new IdentityPoolClient(options); + + await assert.rejects(client.getAccessToken(), expectedError); + }); + + it('should send the correct x-goog-api-client header value', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange( + [ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ], + { + 'x-goog-api-client': getExpectedExternalAccountMetricsHeaderValue( + 'programmatic', + false, + false + ), + } + ) + ); + + const options = { + audience: audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + subject_token_supplier: new TestSubjectTokenSupplier({ + subjectToken: externalSubjectToken, + }), + }; + const client = new IdentityPoolClient(options); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + }); + }); }); + +interface TestSubjectTokenSupplierOptions { + subjectToken?: string; + error?: Error; +} + +class TestSubjectTokenSupplier implements SubjectTokenSupplier { + private readonly subjectToken: string; + private readonly error?: Error; + + constructor(options: TestSubjectTokenSupplierOptions) { + this.subjectToken = options.subjectToken ?? ''; + this.error = options.error; + } + + getSubjectToken(context: ExternalAccountSupplierContext): Promise { + if (this.error) { + throw this.error; + } + return Promise.resolve(this.subjectToken); + } +} From 79b365d324e48df2ac7034cc0a9febeadfda0ab8 Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Wed, 10 Apr 2024 14:19:16 -0700 Subject: [PATCH 3/5] docs: adding documentation for programmatic auth feature (#1790) * docs: adding documentation for programmatic auth feature * fix typos * Apply suggestions from code review Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> Co-authored-by: Daniel Bankhead * add audience placeholder --------- Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> Co-authored-by: Daniel Bankhead --- .readme-partials.yaml | 126 +++++++++++++++++++++++++++++++++ src/auth/awsclient.ts | 44 +++++++++--- src/auth/baseexternalclient.ts | 52 ++++++++++++++ src/auth/identitypoolclient.ts | 30 ++++++++ 4 files changed, 241 insertions(+), 11 deletions(-) diff --git a/.readme-partials.yaml b/.readme-partials.yaml index d4c55051..cce6a02f 100644 --- a/.readme-partials.yaml +++ b/.readme-partials.yaml @@ -356,6 +356,55 @@ body: |- You can now [start using the Auth library](#using-external-identities) to call Google Cloud resources from AWS. + ### Accessing resources from AWS using a custom AWS security credentials supplier. + + In order to access Google Cloud resources from Amazon Web Services (AWS), the following requirements are needed: + - A workload identity pool needs to be created. + - AWS needs to be added as an identity provider in the workload identity pool (The Google [organization policy](https://cloud.google.com/iam/docs/manage-workload-identity-pools-providers#restrict) needs to allow federation from AWS). + - Permission to impersonate a service account needs to be granted to the external identity. + + Follow the detailed [instructions](https://cloud.google.com/iam/docs/access-resources-aws) on how to configure workload identity federation from AWS. + + If you want to use AWS security credentials that cannot be retrieved using methods supported natively by this library, + a custom AwsSecurityCredentialsSupplier implementation may be specified when creating an AWS client. The supplier must + return valid, unexpired AWS security credentials when called by the GCP credential. + + Note that the client does not cache the returned AWS security credentials, so caching logic should be implemented in the supplier to prevent multiple requests for the same resources. + + ```ts + class AwsSupplier implements AwsSecurityCredentialsSupplier { + async getAwsRegion(context: ExternalAccountSupplierContext): Promise { + // Return the current AWS region, i.e. 'us-east-2'. + } + + async getAwsSecurityCredentials( + context: ExternalAccountSupplierContext + ): Promise { + const audience = context.audience; + // Return valid AWS security credentials for the requested audience. + } + } + + const clientOptions = { + audience: '//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID', // Set the GCP audience. + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', // Set the subject token type. + aws_security_credentials_supplier: new AwsSupplier() // Set the custom supplier. + } + + const client = new AwsClient(clientOptions); + ``` + + Where the [audience](https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#provider-audience) is: `//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID` + + Where the following variables need to be substituted: + + * `$PROJECT_NUMBER`: The Google Cloud project number. + * `$WORKLOAD_POOL_ID`: The workload pool ID. + * `$PROVIDER_ID`: The provider ID. + + + The values for audience, service account impersonation URL, and any other builder field can also be found by generating a [credential configuration file with the gcloud CLI](https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/create-cred-config). + ### Access resources from Microsoft Azure In order to access Google Cloud resources from Microsoft Azure, the following requirements are needed: @@ -464,6 +513,44 @@ body: |- - `$URL_TO_GET_OIDC_TOKEN`: The URL of the local server endpoint to call to retrieve the OIDC token. - `$HEADER_KEY` and `$HEADER_VALUE`: The additional header key/value pairs to pass along the GET request to `$URL_TO_GET_OIDC_TOKEN`, e.g. `Metadata-Flavor=Google`. + ### Accessing resources from an OIDC or SAML2.0 identity provider using a custom supplier + + If you want to use OIDC or SAML2.0 that cannot be retrieved using methods supported natively by this library, + a custom SubjectTokenSupplier implementation may be specified when creating an identity pool client. The supplier must + return a valid, unexpired subject token when called by the GCP credential. + + Note that the client does not cache the returned subject token, so caching logic should be implemented in the supplier to prevent multiple requests for the same resources. + + ```ts + class CustomSupplier implements SubjectTokenSupplier { + async getSubjectToken( + context: ExternalAccountSupplierContext + ): Promise { + const audience = context.audience; + const subjectTokenType = context.subjectTokenType; + // Return a valid subject token for the requested audience and subject token type. + } + } + + const clientOptions = { + audience: '//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID', // Set the GCP audience. + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', // Set the subject token type. + subject_token_supplier: new CustomSupplier() // Set the custom supplier. + } + + const client = new CustomSupplier(clientOptions); + ``` + + Where the [audience](https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#provider-audience) is: `//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID` + + Where the following variables need to be substituted: + + * `$PROJECT_NUMBER`: The Google Cloud project number. + * `$WORKLOAD_POOL_ID`: The workload pool ID. + * `$PROVIDER_ID`: The provider ID. + + The values for audience, service account impersonation URL, and any other builder field can also be found by generating a [credential configuration file with the gcloud CLI](https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/create-cred-config). + #### Using External Account Authorized User workforce credentials [External account authorized user credentials](https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#browser-based-sign-in) allow you to sign in with a web browser to an external identity provider account via the @@ -842,6 +929,45 @@ body: |- You can now [use the Auth library](#using-external-identities) to call Google Cloud resources from an OIDC or SAML provider. + ### Accessing resources from an OIDC or SAML2.0 identity provider using a custom supplier + + If you want to use OIDC or SAML2.0 that cannot be retrieved using methods supported natively by this library, + a custom SubjectTokenSupplier implementation may be specified when creating an identity pool client. The supplier must + return a valid, unexpired subject token when called by the GCP credential. + + Note that the client does not cache the returned subject token, so caching logic should be implemented in the supplier to prevent multiple requests for the same resources. + + ```ts + class CustomSupplier implements SubjectTokenSupplier { + async getSubjectToken( + context: ExternalAccountSupplierContext + ): Promise { + const audience = context.audience; + const subjectTokenType = context.subjectTokenType; + // Return a valid subject token for the requested audience and subject token type. + } + } + + const clientOptions = { + audience: '//iam.googleapis.com/locations/global/workforcePools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID', // Set the GCP audience. + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', // Set the subject token type. + subject_token_supplier: new CustomSupplier() // Set the custom supplier. + } + + const client = new CustomSupplier(clientOptions); + ``` + + Where the audience is: `//iam.googleapis.com/locations/global/workforcePools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID` + + Where the following variables need to be substituted: + + * `WORKFORCE_POOL_ID`: The worforce pool ID. + * `$PROVIDER_ID`: The provider ID. + + and the workforce pool user project is the project number associated with the [workforce pools user project](https://cloud.google.com/iam/docs/workforce-identity-federation#workforce-pools-user-project). + + The values for audience, service account impersonation URL, and any other builder field can also be found by generating a [credential configuration file with the gcloud CLI](https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#use_configuration_files_for_sign-in). + ### Using External Identities External identities (AWS, Azure and OIDC-based providers) can be used with `Application Default Credentials`. diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index f55824d3..87112bb5 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -26,24 +26,46 @@ import {originalOrCamelOptions, SnakeToCamelObject} from '../util'; * AWS credentials JSON interface. This is used for AWS workloads. */ export interface AwsClientOptions extends BaseExternalAccountClientOptions { + /** + * Object containing options to retrieve AWS security credentials. A valid credential + * source or a aws security credentials supplier should be specified. + */ credential_source?: { + /** + * AWS environment ID. Currently only 'AWS1' is supported. + */ environment_id: string; - // Region can also be determined from the AWS_REGION or AWS_DEFAULT_REGION - // environment variables. + /** + * The EC2 metadata URL to retrieve the current AWS region from. If this is + * not provided, the region should be present in the AWS_REGION or AWS_DEFAULT_REGION + * environment variables. + */ region_url?: string; - // The url field is used to determine the AWS security credentials. - // This is optional since these credentials can be retrieved from the - // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN - // environment variables. + /** + * The EC2 metadata URL to retrieve AWS security credentials. If this is not provided, + * the credentials should be present in the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, + * and AWS_SESSION_TOKEN environment variables. + */ url?: string; + /** + * The regional GetCallerIdentity action URL, used to determine the account + * ID and its roles. + */ regional_cred_verification_url: string; - // The imdsv2 session token url is used to fetch session token from AWS - // which is later sent through headers for metadata requests. If the - // field is missing, then session token won't be fetched and sent with - // the metadata requests. - // The session token is required for IMDSv2 but optional for IMDSv1 + /** + * The imdsv2 session token url is used to fetch session token from AWS + * which is later sent through headers for metadata requests. If the + * field is missing, then session token won't be fetched and sent with + * the metadata requests. + * The session token is required for IMDSv2 but optional for IMDSv1 + */ imdsv2_session_token_url?: string; }; + /** + * The AWS security credentials supplier to call to retrieve the AWS region + * and AWS security credentials. Either this or a valid credential source + * must be specified. + */ aws_security_credentials_supplier?: AwsSecurityCredentialsSupplier; } diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index c7b81710..28417474 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -74,8 +74,21 @@ const pkg = require('../../../package.json'); */ export {DEFAULT_UNIVERSE} from './authclient'; +/** + * Shared options used to build {@link ExternalAccountClient} and + * {@link ExternalAccountAuthorizedUserClient}. + */ export interface SharedExternalAccountClientOptions extends AuthClientOptions { + /** + * The Security Token Service audience, which is usually the fully specified + * resource name of the workload or workforce pool provider. + */ audience: string; + /** + * The Security Token Service token URL used to exchange the third party token + * for a GCP access token. If not provided, will default to + * 'https://sts.googleapis.com/v1/token' + */ token_url?: string; } @@ -109,16 +122,55 @@ export interface ExternalAccountSupplierContext { */ export interface BaseExternalAccountClientOptions extends SharedExternalAccountClientOptions { + /** + * Credential type, should always be 'external_account'. + */ type?: string; + /** + * The Security Token Service subject token type based on the OAuth 2.0 + * token exchange spec. 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' + */ subject_token_type: string; + /** + * The URL for the service account impersonation request. This URL is required + * for some APIs. If this URL is not available, the access token from the + * Security Token Service is used directly. + */ service_account_impersonation_url?: string; + /** + * Object containing additional options for service account impersonation. + */ service_account_impersonation?: { + /** + * The desired lifetime of the impersonated service account access token. + * If not provided, the default lifetime will be 3600 seconds. + */ token_lifetime_seconds?: number; }; + /** + * The endpoint used to retrieve account related information. + */ token_info_url?: string; + /** + * Client ID of the service account from the console. + */ client_id?: string; + /** + * Client secret of the service account from the console. + */ client_secret?: string; + /** + * The workforce pool user project. Required when using a workforce identity + * pool. + */ workforce_pool_user_project?: string; + /** + * The scopes to request during the authorization grant. + */ scopes?: string[]; /** * @example diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 54f58fcc..9ed8f803 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -53,17 +53,47 @@ export interface SubjectTokenSupplier { */ export interface IdentityPoolClientOptions extends BaseExternalAccountClientOptions { + /** + * Object containing options to retrieve identity pool credentials. A valid credential + * source or a subject token supplier must be specified. + */ credential_source?: { + /** + * The file location to read the subject token from. Either this or a URL + * should be specified. + */ file?: string; + /** + * The URL to call to retrieve the subject token. Either this or a file + * location should be specified. + */ url?: string; + /** + * Optional headers to send on the request to the specified URL. + */ headers?: { [key: string]: string; }; + /** + * The format that the subject token is in the file or the URL response. + * If not provided, will default to reading the text string directly. + */ format?: { + /** + * The format type. Can either be 'text' or 'json'. + */ type: SubjectTokenFormatType; + /** + * The field name containing the subject token value if the type is 'json'. + */ subject_token_field_name?: string; }; }; + /** + * The subject token supplier to call to retrieve the subject token to exchange + * for a GCP access token. Either this or a valid credential source should + * be specified. + */ subject_token_supplier?: SubjectTokenSupplier; } From 88647553813163beb10725cb423bcc688bcfc167 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Tue, 16 Apr 2024 16:26:10 +0000 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20po?= =?UTF-8?q?st-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- README.md | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/README.md b/README.md index 884cbbc9..55d86876 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,55 @@ The gcloud create-cred-config command will be updated to support this soon. You can now [start using the Auth library](#using-external-identities) to call Google Cloud resources from AWS. +### Accessing resources from AWS using a custom AWS security credentials supplier. + +In order to access Google Cloud resources from Amazon Web Services (AWS), the following requirements are needed: +- A workload identity pool needs to be created. +- AWS needs to be added as an identity provider in the workload identity pool (The Google [organization policy](https://cloud.google.com/iam/docs/manage-workload-identity-pools-providers#restrict) needs to allow federation from AWS). +- Permission to impersonate a service account needs to be granted to the external identity. + +Follow the detailed [instructions](https://cloud.google.com/iam/docs/access-resources-aws) on how to configure workload identity federation from AWS. + +If you want to use AWS security credentials that cannot be retrieved using methods supported natively by this library, +a custom AwsSecurityCredentialsSupplier implementation may be specified when creating an AWS client. The supplier must +return valid, unexpired AWS security credentials when called by the GCP credential. + +Note that the client does not cache the returned AWS security credentials, so caching logic should be implemented in the supplier to prevent multiple requests for the same resources. + +```ts +class AwsSupplier implements AwsSecurityCredentialsSupplier { + async getAwsRegion(context: ExternalAccountSupplierContext): Promise { + // Return the current AWS region, i.e. 'us-east-2'. + } + + async getAwsSecurityCredentials( + context: ExternalAccountSupplierContext + ): Promise { + const audience = context.audience; + // Return valid AWS security credentials for the requested audience. + } +} + +const clientOptions = { + audience: '//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID', // Set the GCP audience. + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', // Set the subject token type. + aws_security_credentials_supplier: new AwsSupplier() // Set the custom supplier. +} + +const client = new AwsClient(clientOptions); +``` + +Where the [audience](https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#provider-audience) is: `//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID` + +Where the following variables need to be substituted: + +* `$PROJECT_NUMBER`: The Google Cloud project number. +* `$WORKLOAD_POOL_ID`: The workload pool ID. +* `$PROVIDER_ID`: The provider ID. + + +The values for audience, service account impersonation URL, and any other builder field can also be found by generating a [credential configuration file with the gcloud CLI](https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/create-cred-config). + ### Access resources from Microsoft Azure In order to access Google Cloud resources from Microsoft Azure, the following requirements are needed: @@ -508,6 +557,44 @@ Where the following variables need to be substituted: - `$URL_TO_GET_OIDC_TOKEN`: The URL of the local server endpoint to call to retrieve the OIDC token. - `$HEADER_KEY` and `$HEADER_VALUE`: The additional header key/value pairs to pass along the GET request to `$URL_TO_GET_OIDC_TOKEN`, e.g. `Metadata-Flavor=Google`. +### Accessing resources from an OIDC or SAML2.0 identity provider using a custom supplier + +If you want to use OIDC or SAML2.0 that cannot be retrieved using methods supported natively by this library, +a custom SubjectTokenSupplier implementation may be specified when creating an identity pool client. The supplier must +return a valid, unexpired subject token when called by the GCP credential. + +Note that the client does not cache the returned subject token, so caching logic should be implemented in the supplier to prevent multiple requests for the same resources. + +```ts +class CustomSupplier implements SubjectTokenSupplier { + async getSubjectToken( + context: ExternalAccountSupplierContext + ): Promise { + const audience = context.audience; + const subjectTokenType = context.subjectTokenType; + // Return a valid subject token for the requested audience and subject token type. + } +} + +const clientOptions = { + audience: '//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID', // Set the GCP audience. + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', // Set the subject token type. + subject_token_supplier: new CustomSupplier() // Set the custom supplier. +} + +const client = new CustomSupplier(clientOptions); +``` + +Where the [audience](https://cloud.google.com/iam/docs/best-practices-for-using-workload-identity-federation#provider-audience) is: `//iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID` + +Where the following variables need to be substituted: + +* `$PROJECT_NUMBER`: The Google Cloud project number. +* `$WORKLOAD_POOL_ID`: The workload pool ID. +* `$PROVIDER_ID`: The provider ID. + +The values for audience, service account impersonation URL, and any other builder field can also be found by generating a [credential configuration file with the gcloud CLI](https://cloud.google.com/sdk/gcloud/reference/iam/workload-identity-pools/create-cred-config). + #### Using External Account Authorized User workforce credentials [External account authorized user credentials](https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#browser-based-sign-in) allow you to sign in with a web browser to an external identity provider account via the @@ -886,6 +973,45 @@ credentials unless they do not meet your specific requirements. You can now [use the Auth library](#using-external-identities) to call Google Cloud resources from an OIDC or SAML provider. +### Accessing resources from an OIDC or SAML2.0 identity provider using a custom supplier + +If you want to use OIDC or SAML2.0 that cannot be retrieved using methods supported natively by this library, +a custom SubjectTokenSupplier implementation may be specified when creating an identity pool client. The supplier must +return a valid, unexpired subject token when called by the GCP credential. + +Note that the client does not cache the returned subject token, so caching logic should be implemented in the supplier to prevent multiple requests for the same resources. + +```ts +class CustomSupplier implements SubjectTokenSupplier { + async getSubjectToken( + context: ExternalAccountSupplierContext + ): Promise { + const audience = context.audience; + const subjectTokenType = context.subjectTokenType; + // Return a valid subject token for the requested audience and subject token type. + } +} + +const clientOptions = { + audience: '//iam.googleapis.com/locations/global/workforcePools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID', // Set the GCP audience. + subject_token_type: 'urn:ietf:params:oauth:token-type:id_token', // Set the subject token type. + subject_token_supplier: new CustomSupplier() // Set the custom supplier. +} + +const client = new CustomSupplier(clientOptions); +``` + +Where the audience is: `//iam.googleapis.com/locations/global/workforcePools/$WORKLOAD_POOL_ID/providers/$PROVIDER_ID` + +Where the following variables need to be substituted: + +* `WORKFORCE_POOL_ID`: The worforce pool ID. +* `$PROVIDER_ID`: The provider ID. + +and the workforce pool user project is the project number associated with the [workforce pools user project](https://cloud.google.com/iam/docs/workforce-identity-federation#workforce-pools-user-project). + +The values for audience, service account impersonation URL, and any other builder field can also be found by generating a [credential configuration file with the gcloud CLI](https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#use_configuration_files_for_sign-in). + ### Using External Identities External identities (AWS, Azure and OIDC-based providers) can be used with `Application Default Credentials`. From cc5718ceb703d67db1a2d3f51f3e234acaf6af6e Mon Sep 17 00:00:00 2001 From: aeitzman Date: Tue, 16 Apr 2024 13:04:32 -0700 Subject: [PATCH 5/5] fix lint --- test/test.baseexternalclient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 46b80359..a0974a85 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -294,7 +294,7 @@ describe('BaseExternalAccountClient', () => { scope.done(); }); - it('should set universe domain on default token url', async() => { + it('should set universe domain on default token url', async () => { const options: BaseExternalAccountClientOptions = { ...externalAccountOptionsNoUrl, universe_domain: 'test.com',