diff --git a/README.md b/README.md index da54ef90..375f7bbc 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,19 @@ npm install google-auth-library ## Ways to authenticate This library provides a variety of ways to authenticate to your Google services. -- [Application Default Credentials](#choosing-the-correct-credential-type-automatically) - Use Application Default Credentials when you use a single identity for all users in your application. Especially useful for applications running on Google Cloud. +- [Application Default Credentials](#choosing-the-correct-credential-type-automatically) - Use Application Default Credentials when you use a single identity for all users in your application. Especially useful for applications running on Google Cloud. Application Default Credentials also support workload identity federation to access Google Cloud resources from non-Google Cloud platforms. - [OAuth 2](#oauth2) - Use OAuth2 when you need to perform actions on behalf of the end user. - [JSON Web Tokens](#json-web-tokens) - Use JWT when you are using a single identity for all users. Especially useful for server->server or server->API communication. - [Google Compute](#compute) - Directly use a service account on Google Cloud Platform. Useful for server->server or server->API communication. +- [Workload Identity Federation](#workload-identity-federation) - Use workload identity federation to access Google Cloud resources from Amazon Web Services (AWS), Microsoft Azure or any identity provider that supports OpenID Connect (OIDC). ## Application Default Credentials This library provides an implementation of [Application Default Credentials](https://cloud.google.com/docs/authentication/getting-started)for Node.js. The [Application Default Credentials](https://cloud.google.com/docs/authentication/getting-started) provide a simple way to get authorization credentials for use in calling Google APIs. They are best suited for cases when the call needs to have the same identity and authorization level for the application independent of the user. This is the recommended approach to authorize calls to Cloud APIs, particularly when you're building an application that uses Google Cloud Platform. +Application Default Credentials also support workload identity federation to access Google Cloud resources from non-Google Cloud platforms including Amazon Web Services (AWS), Microsoft Azure or any identity provider that supports OpenID Connect (OIDC). Workload identity federation is recommended for non-Google Cloud environments as it avoids the need to download, manage and store service account private keys locally, see: [Workload Identity Federation](#workload-identity-federation). + #### Download your Service Account Credentials JSON file To use Application Default Credentials, You first need to download a set of JSON credentials for your project. Go to **APIs & Auth** > **Credentials** in the [Google Developers Console](https://console.cloud.google.com/) and select **Service account** from the **Add credentials** dropdown. @@ -363,6 +366,213 @@ async function main() { main().catch(console.error); ``` +## Workload Identity Federation + +Using workload identity federation, your application can access Google Cloud resources from Amazon Web Services (AWS), Microsoft Azure or any identity provider that supports OpenID Connect (OIDC). + +Traditionally, applications running outside Google Cloud have used service account keys to access Google Cloud resources. Using identity federation, you can allow your workload to impersonate a service account. +This lets you access Google Cloud resources directly, eliminating the maintenance and security burden associated with service account keys. + +### Accessing resources from AWS + +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. + +After configuring the AWS provider to impersonate a service account, a credential configuration file needs to be generated. +Unlike service account credential files, the generated credential configuration file will only contain non-sensitive metadata to instruct the library on how to retrieve external subject tokens and exchange them for service account access tokens. +The configuration file can be generated by using the [gcloud CLI](https://cloud.google.com/sdk/). + +To generate the AWS workload identity configuration, run the following command: + +```bash +# Generate an AWS configuration file. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$AWS_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --aws \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$AWS_PROVIDER_ID`: The AWS provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. + +This will generate the configuration file in the specified output file. + +You can now [start using the Auth library](#using-external-identities) to call Google Cloud resources from AWS. + +### Access resources from Microsoft Azure + +In order to access Google Cloud resources from Microsoft Azure, the following requirements are needed: +- A workload identity pool needs to be created. +- Azure 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 Azure). +- The Azure tenant needs to be configured for identity federation. +- 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-azure) on how to configure workload identity federation from Microsoft Azure. + +After configuring the Azure provider to impersonate a service account, a credential configuration file needs to be generated. +Unlike service account credential files, the generated credential configuration file will only contain non-sensitive metadata to instruct the library on how to retrieve external subject tokens and exchange them for service account access tokens. +The configuration file can be generated by using the [gcloud CLI](https://cloud.google.com/sdk/). + +To generate the Azure workload identity configuration, run the following command: + +```bash +# Generate an Azure configuration file. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$AZURE_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --azure \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$AZURE_PROVIDER_ID`: The Azure provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. + +This will generate the configuration file in the specified output file. + +You can now [start using the Auth library](#using-external-identities) to call Google Cloud resources from Azure. + +### Accessing resources from an OIDC identity provider + +In order to access Google Cloud resources from an identity provider that supports [OpenID Connect (OIDC)](https://openid.net/connect/), the following requirements are needed: +- A workload identity pool needs to be created. +- An OIDC identity provider needs to be added 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 the identity provider). +- 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-oidc) on how to configure workload identity federation from an OIDC identity provider. + +After configuring the OIDC provider to impersonate a service account, a credential configuration file needs to be generated. +Unlike service account credential files, the generated credential configuration file will only contain non-sensitive metadata to instruct the library on how to retrieve external subject tokens and exchange them for service account access tokens. +The configuration file can be generated by using the [gcloud CLI](https://cloud.google.com/sdk/). + +For OIDC providers, the Auth library can retrieve OIDC tokens either from a local file location (file-sourced credentials) or from a local server (URL-sourced credentials). + +**File-sourced credentials** +For file-sourced credentials, a background process needs to be continuously refreshing the file location with a new OIDC token prior to expiration. +For tokens with one hour lifetimes, the token needs to be updated in the file every hour. The token can be stored directly as plain text or in JSON format. + +To generate a file-sourced OIDC configuration, run the following command: + +```bash +# Generate an OIDC configuration file for file-sourced credentials. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$OIDC_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-source-file $PATH_TO_OIDC_ID_TOKEN \ + # Optional arguments for file types. Default is "text": + # --credential-source-type "json" \ + # Optional argument for the field that contains the OIDC credential. + # This is required for json. + # --credential-source-field-name "id_token" \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$OIDC_PROVIDER_ID`: The OIDC provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. +- `$PATH_TO_OIDC_ID_TOKEN`: The file path where the OIDC token will be retrieved from. + +This will generate the configuration file in the specified output file. + +**URL-sourced credentials** +For URL-sourced credentials, a local server needs to host a GET endpoint to return the OIDC token. The response can be in plain text or JSON. +Additional required request headers can also be specified. + +To generate a URL-sourced OIDC workload identity configuration, run the following command: + +```bash +# Generate an OIDC configuration file for URL-sourced credentials. +gcloud iam workload-identity-pools create-cred-config \ + projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/$POOL_ID/providers/$OIDC_PROVIDER_ID \ + --service-account $SERVICE_ACCOUNT_EMAIL \ + --credential-source-url $URL_TO_GET_OIDC_TOKEN \ + --credential-source-headers $HEADER_KEY=$HEADER_VALUE \ + # Optional arguments for file types. Default is "text": + # --credential-source-type "json" \ + # Optional argument for the field that contains the OIDC credential. + # This is required for json. + # --credential-source-field-name "id_token" \ + --output-file /path/to/generated/config.json +``` + +Where the following variables need to be substituted: +- `$PROJECT_NUMBER`: The Google Cloud project number. +- `$POOL_ID`: The workload identity pool ID. +- `$OIDC_PROVIDER_ID`: The OIDC provider ID. +- `$SERVICE_ACCOUNT_EMAIL`: The email of the service account to impersonate. +- `$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`. + +You can now [start using the Auth library](#using-external-identities) to call Google Cloud resources from an OIDC provider. + +### Using External Identities + +External identities (AWS, Azure and OIDC-based providers) can be used with `Application Default Credentials`. +In order to use external identities with Application Default Credentials, you need to generate the JSON credentials configuration file for your external identity as described above. +Once generated, store the path to this file in the `GOOGLE_APPLICATION_CREDENTIALS` environment variable. + +```bash +export GOOGLE_APPLICATION_CREDENTIALS=/path/to/config.json +``` + +The library can now automatically choose the right type of client and initialize credentials from the context provided in the configuration file. + +```js +async function main() { + const auth = new GoogleAuth({ + scopes: 'https://www.googleapis.com/auth/cloud-platform' + }); + const client = await auth.getClient(); + const projectId = await auth.getProjectId(); + // List all buckets in a project. + const url = `https://storage.googleapis.com/storage/v1/b?project=${projectId}`; + const res = await client.request({ url }); + console.log(res.data); +} +``` + +When using external identities with Application Default Credentials in Node.js, the `roles/browser` role needs to be granted to the service account. +The `Cloud Resource Manager API` should also be enabled on the project. +This is needed since the library will try to auto-discover the project ID from the current environment using the impersonated credential. +To avoid this requirement, the project ID can be explicitly specified on initialization. + +```js +const auth = new GoogleAuth({ + scopes: 'https://www.googleapis.com/auth/cloud-platform', + // Pass the project ID explicitly to avoid the need to grant `roles/browser` to the service account + // or enable Cloud Resource Manager API on the project. + projectId: 'CLOUD_RESOURCE_PROJECT_ID', +}); +``` + +You can also explicitly initialize external account clients using the generated configuration file. + +```js +const {ExternalAccountClient} = require('google-auth-library'); +const jsonConfig = require('/path/to/config.json'); + +async function main() { + const client = ExternalAccountClient.fromJSON(jsonConfig); + client.scopes = ['https://www.googleapis.com/auth/cloud-platform']; + // List all buckets in a project. + const url = `https://storage.googleapis.com/storage/v1/b?project=${projectId}`; + const res = await client.request({url}); + console.log(res.data); +} +``` + ## Working with ID Tokens ### Fetching ID Tokens If your application is running on Cloud Run or Cloud Functions, or using Cloud Identity-Aware diff --git a/browser-test/test.crypto.ts b/browser-test/test.crypto.ts index aaa13b3f..5da17f98 100644 --- a/browser-test/test.crypto.ts +++ b/browser-test/test.crypto.ts @@ -14,7 +14,7 @@ import * as base64js from 'base64-js'; import {assert} from 'chai'; -import {createCrypto} from '../src/crypto/crypto'; +import {createCrypto, fromArrayBufferToHex} from '../src/crypto/crypto'; import {BrowserCrypto} from '../src/crypto/browser/crypto'; import {privateKey, publicKey} from './fixtures/keys'; import {describe, it} from 'mocha'; @@ -99,4 +99,56 @@ describe('Browser crypto tests', () => { const encodedString = crypto.encodeBase64StringUtf8(originalString); assert.strictEqual(encodedString, base64String); }); + + it('should calculate SHA256 digest in hex encoding', async () => { + const input = 'I can calculate SHA256'; + const expectedHexDigest = + '73d08486d8bfd4fb4bc12dd8903604ddbde5ad95b6efa567bd723ce81a881122'; + + const calculatedHexDigest = await crypto.sha256DigestHex(input); + assert.strictEqual(calculatedHexDigest, expectedHexDigest); + }); + + describe('should compute the HMAC-SHA256 hash of a message', () => { + it('using a string key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + const key = 'key'; + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const expectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, expectedHash.buffer); + }); + + it('using an ArrayBuffer key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + // String "key" ArrayBuffer representation. + const key = new Uint8Array([107, 0, 101, 0, 121, 0]) + .buffer as ArrayBuffer; + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const expectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, expectedHash.buffer); + }); + }); + + it('should expose a method to convert an ArrayBuffer to hex', () => { + const arrayBuffer = new Uint8Array([4, 8, 0, 12, 16, 0]) + .buffer as ArrayBuffer; + const expectedHexEncoding = '0408000c1000'; + + const calculatedHexEncoding = fromArrayBufferToHex(arrayBuffer); + assert.strictEqual(calculatedHexEncoding, expectedHexEncoding); + }); }); diff --git a/package.json b/package.json index 69b87073..c4737d1e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ }, "devDependencies": { "@compodoc/compodoc": "^1.1.7", + "@microsoft/api-documenter": "^7.8.10", + "@microsoft/api-extractor": "^7.8.10", "@types/base64-js": "^1.2.5", "@types/chai": "^4.1.7", "@types/jws": "^3.1.0", @@ -67,9 +69,7 @@ "ts-loader": "^8.0.0", "typescript": "^3.8.3", "webpack": "^4.20.2", - "webpack-cli": "^4.0.0", - "@microsoft/api-documenter": "^7.8.10", - "@microsoft/api-extractor": "^7.8.10" + "webpack-cli": "^4.0.0" }, "files": [ "build/src", @@ -84,6 +84,7 @@ "fix": "gts fix", "pretest": "npm run compile", "docs": "compodoc src/", + "samples-setup": "cd samples/ && npm link ../ && npm run setup && cd ../", "samples-test": "cd samples/ && npm link ../ && npm test && cd ../", "system-test": "mocha build/system-test --timeout 60000", "presystem-test": "npm run compile", diff --git a/samples/package.json b/samples/package.json index 75269a2a..b9b5064d 100644 --- a/samples/package.json +++ b/samples/package.json @@ -5,6 +5,7 @@ "*.js" ], "scripts": { + "setup": "node scripts/externalclient-setup.js", "test": "mocha --timeout 60000" }, "engines": { diff --git a/samples/scripts/externalclient-setup.js b/samples/scripts/externalclient-setup.js new file mode 100755 index 00000000..e8a5ce4b --- /dev/null +++ b/samples/scripts/externalclient-setup.js @@ -0,0 +1,298 @@ +// Copyright 2021 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. + +// This script is used to generate the project configurations needed to +// end-to-end test workload identity pools in the Auth library, specifically +// file-sourced, URL-sourced OIDC-based credentials and AWS credentials. +// This is done via the sample test: samples/test/externalclient.test.js. +// +// In order to run this script, the GOOGLE_APPLICATION_CREDENTIALS environment +// variable needs to be set to point to a service account key file. +// Additional AWS related information (AWS account ID and AWS role name) also +// need to be provided in this file. Detailed instructions are documented below. +// +// GCP project changes: +// -------------------- +// The following IAM roles need to be set on the service account: +// 1. IAM Workload Identity Pool Admin (needed to create resources for workload +// identity pools). +// 2. Security Admin (needed to get and set IAM policies). +// 3. Service Account Token Creator (needed to generate Google ID tokens and +// access tokens). +// +// The following APIs need to be enabled on the project: +// 1. Identity and Access Management (IAM) API. +// 2. IAM Service Account Credentials API. +// 3. Cloud Resource Manager API. +// 4. The API being accessed in the test, eg. DNS. +// +// AWS developer account changes: +// ------------------------------ +// For testing AWS credentials, the following are needed: +// 1. An AWS developer account is needed. The account ID will need to +// be provided in the configuration object below. +// 2. A role for web identity federation. This will also need to be provided +// in the configuration object below. +// - An OIDC Google identity provider needs to be created with the following: +// issuer: accounts.google.com +// audience: Use the client_id of the service account. +// - A role for OIDC web identity federation is needed with the created +// Google provider as a trusted entity: +// "accounts.google.com:aud": "$CLIENT_ID" +// The role creation steps are documented at: +// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html +// +// This script needs to be run once. It will do the following: +// 1. Create a random workload identity pool. +// 2. Create a random OIDC provider in that pool which uses the +// accounts.google.com as the issuer and the default STS audience as the +// allowed audience. This audience will be validated on STS token exchange. +// 3. Enable OIDC tokens generated by the current service account to impersonate +// the service account. (Identified by the OIDC token sub field which is the +// service account client ID). +// 4. Create a random AWS provider in that pool which uses the provided AWS +// account ID. +// 5. Enable AWS provider to impersonate the service account. (Principal is +// identified by the AWS role name). +// 6. Print out the STS audience fields associated with the created providers +// after the setup completes successfully so that they can be used in the +// tests. These will be copied and used as the global AUDIENCE_OIDC and +// AUDIENCE_AWS constants in samples/test/externalclient.test.js. +// An additional AWS_ROLE_ARN field will be printed out and also needs +// to be copied to the test file. This will be used as the AWS role for +// AssumeRoleWithWebIdentity when federating from GCP to AWS. +// The same service account used for this setup script should be used for +// the test script. +// +// It is safe to run the setup script again. A new pool is created and new +// audiences are printed. If run multiple times, it is advisable to delete +// unused pools. Note that deleted pools are soft deleted and may remain for +// a while before they are completely deleted. The old pool ID cannot be used +// in the meantime. + +const fs = require('fs'); +const {promisify} = require('util'); +const {GoogleAuth} = require('google-auth-library'); + +const readFile = promisify(fs.readFile); + +/** + * Generates a random string of the specified length, optionally using the + * specified alphabet. + * + * @param {number} length The length of the string to generate. + * @return {string} A random string of the provided length. + */ +function generateRandomString(length) { + const chars = []; + const allowedChars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < length; i++) { + chars.push( + allowedChars.charAt(Math.floor(Math.random() * allowedChars.length)) + ); + } + return chars.join(''); +} + +/** + * Creates a workload identity pool with an OIDC provider which will accept + * Google OIDC tokens generated from the current service account where the token + * will have sub as the service account client ID and the audience as the + * created identity pool STS audience. + * The steps followed here mirror the instructions for configuring federation + * with an OIDC provider illustrated at: + * https://cloud.google.com/iam/docs/access-resources-oidc + * This will also create an AWS provider in the same workload identity pool + * using the AWS account ID and AWS ARN role name provided. + * The steps followed here mirror the instructions for configuring federation + * with an AWS provider illustrated at: + * https://cloud.google.com/iam/docs/access-resources-aws + * @param {Object} config An object containing additional data needed to + * configure the external account client setup. + * @return {Promise} A promise that resolves with an object containing + * the STS audience corresponding with the generated workload identity pool + * OIDC provider and AWS provider. + */ +async function main(config) { + let response; + const keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; + if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + throw new Error('No GOOGLE_APPLICATION_CREDENTIALS env var is available'); + } + const keys = JSON.parse(await readFile(keyFile, 'utf8')); + const suffix = generateRandomString(10); + const poolId = `pool-${suffix}`; + const oidcProviderId = `oidc-${suffix}`; + const awsProviderId = `aws-${suffix}`; + const projectId = keys.project_id; + const clientEmail = keys.client_email; + const sub = keys.client_id; + const auth = new GoogleAuth({ + scopes: 'https://www.googleapis.com/auth/cloud-platform', + }); + + // TODO: switch to using IAM client SDK once v1 API has all the v1beta + // changes. + // https://cloud.google.com/iam/docs/reference/rest/v1beta/projects.locations.workloadIdentityPools + // https://github.com/googleapis/google-api-nodejs-client/tree/master/src/apis/iam + + // Create the workload identity pool. + response = await auth.request({ + url: + `https://iam.googleapis.com/v1beta/projects/${projectId}/` + + `locations/global/workloadIdentityPools?workloadIdentityPoolId=${poolId}`, + method: 'POST', + data: { + displayName: 'Test workload identity pool', + description: 'Test workload identity pool for Node.js', + }, + }); + // Populate the audience field. This will be used by the tests, specifically + // the credential configuration file. + const poolResourcePath = response.data.name.split('/operations')[0]; + const oidcAudience = `//iam.googleapis.com/${poolResourcePath}/providers/${oidcProviderId}`; + const awsAudience = `//iam.googleapis.com/${poolResourcePath}/providers/${awsProviderId}`; + + // Allow service account impersonation. + // Get the existing IAM policity bindings on the current service account. + response = await auth.request({ + url: + `https://iam.googleapis.com/v1/projects/${projectId}/` + + `serviceAccounts/${clientEmail}:getIamPolicy`, + method: 'POST', + }); + const bindings = response.data.bindings || []; + // If not found, add roles/iam.workloadIdentityUser role binding to the + // workload identity pool member. + // For OIDC providers, we will use the value mapped to google.subject. + // This is the sub field of the OIDC token which is the service account + // client_id. + // For AWS providers, we will use the AWS role attribute. This will be the + // assumed role by AssumeRoleWithWebIdentity. + let found = false; + bindings.forEach(binding => { + if (binding.role === 'roles/iam.workloadIdentityUser') { + found = true; + binding.members = [ + `principal://iam.googleapis.com/${poolResourcePath}/subject/${sub}`, + `principalSet://iam.googleapis.com/${poolResourcePath}/` + + `attribute.aws_role/arn:aws:sts::${config.awsAccountId}:assumed-role/` + + `${config.awsRoleName}`, + ]; + } + }); + if (!found) { + bindings.push({ + role: 'roles/iam.workloadIdentityUser', + members: [ + `principal://iam.googleapis.com/${poolResourcePath}/subject/${sub}`, + `principalSet://iam.googleapis.com/${poolResourcePath}/` + + `attribute.aws_role/arn:aws:sts::${config.awsAccountId}:assumed-role/` + + `${config.awsRoleName}`, + ], + }); + } + await auth.request({ + url: + `https://iam.googleapis.com/v1/projects/${projectId}/` + + `serviceAccounts/${clientEmail}:setIamPolicy`, + method: 'POST', + data: { + policy: { + bindings, + }, + }, + }); + + // Create an OIDC provider. This will use the accounts.google.com issuer URL. + // This will use the STS audience as the OIDC token audience. + await auth.request({ + url: + `https://iam.googleapis.com/v1beta/projects/${projectId}/` + + `locations/global/workloadIdentityPools/${poolId}/providers?` + + `workloadIdentityPoolProviderId=${oidcProviderId}`, + method: 'POST', + data: { + displayName: 'Test OIDC provider', + description: 'Test OIDC provider for Node.js', + attributeMapping: { + 'google.subject': 'assertion.sub', + }, + oidc: { + issuerUri: 'https://accounts.google.com', + allowedAudiences: [oidcAudience], + }, + }, + }); + + // Create an AWS provider. + await auth.request({ + url: + `https://iam.googleapis.com/v1beta/projects/${projectId}/` + + `locations/global/workloadIdentityPools/${poolId}/providers?` + + `workloadIdentityPoolProviderId=${awsProviderId}`, + method: 'POST', + data: { + displayName: 'Test AWS provider', + description: 'Test AWS provider for Node.js', + aws: { + accountId: config.awsAccountId, + }, + }, + }); + + return { + oidcAudience, + awsAudience, + }; +} + +// Additional configuration input needed to configure the workload +// identity pool. For AWS tests, an AWS developer account is needed. +// The following AWS prerequisite setup is needed. +// 1. An OIDC Google identity provider needs to be created with the following: +// issuer: accounts.google.com +// audience: Use the client_id of the service account. +// 2. A role for OIDC web identity federation is needed with the created Google +// provider as a trusted entity: +// "accounts.google.com:aud": "$CLIENT_ID" +// The steps are documented at: +// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html +const config = { + // The role name for web identity federation. + awsRoleName: 'ci-nodejs-test', + // The AWS account ID. + awsAccountId: '077071391996', +}; + +// On execution, the following will be printed to the screen: +// AUDIENCE_OIDC: generated OIDC provider audience. +// AUDIENCE_AWS: generated AWS provider audience. +// AWS_ROLE_ARN: This is the AWS role for AssumeRoleWithWebIdentity. +// This should be updated in test/externalclient.test.js. +// Some delay is needed before running the tests in test/externalclient.test.js +// to ensure IAM policies propagate before running sample tests. +// Normally 1-2 minutes should suffice. +main(config) + .then(audiences => { + console.log( + 'The following constants need to be set in test/externalclient.test.js' + ); + console.log(`AUDIENCE_OIDC='${audiences.oidcAudience}'`); + console.log(`AUDIENCE_AWS='${audiences.awsAudience}'`); + console.log( + `AWS_ROLE_ARN='arn:aws::iam::${config.awsAccountId}:role/${config.awsRoleName}'` + ); + }) + .catch(console.error); diff --git a/samples/test/externalclient.test.js b/samples/test/externalclient.test.js new file mode 100644 index 00000000..05f699ef --- /dev/null +++ b/samples/test/externalclient.test.js @@ -0,0 +1,392 @@ +// Copyright 2021 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. + +// Prerequisites: +// Make sure to run the setup in samples/scripts/externalclient-setup.js +// and copy the logged constant strings (AUDIENCE_OIDC, AUDIENCE_AWS and +// AWS_ROLE_ARN) into this file before running this test suite. +// Once that is done, this test can be run indefinitely. +// +// The only requirement for this test suite to run is to set the environment +// variable GOOGLE_APPLICATION_CREDENTIALS to point to the same service account +// keys used in the setup script. +// +// This script follows the following logic. +// 1. OIDC provider (file-sourced and url-sourced credentials): +// Use the service account keys to generate a Google ID token using the +// iamcredentials generateIdToken API, using the default STS audience. +// This will use the service account client ID as the sub field of the token. +// This OIDC token will be used as the external subject token to be exchanged +// for a Google access token via GCP STS endpoint and then to impersonate the +// original service account key. This is abstracted by the GoogleAuth library. +// 2. AWS provider: +// Use the service account keys to generate a Google ID token using the +// iamcredentials generateIdToken API, using the client_id as audience. +// Exchange the OIDC ID token for AWS security keys using AWS STS +// AssumeRoleWithWebIdentity API. These values will be set as AWS environment +// variables to simulate an AWS VM. The Auth library can now read these +// variables and create a signed request to AWS GetCallerIdentity. This will +// be used as the external subject token to be exchanged for a Google access +// token via GCP STS endpoint and then to impersonate the original service +// account key. This is abstracted by the GoogleAuth library. +// +// OIDC provider tests for file-sourced and url-sourced credentials +// ---------------------------------------------------------------- +// The test suite will run tests for file-sourced and url-sourced credentials. +// In both cases, the same Google OIDC token is used as the underlying subject +// token. +// +// AWS provider tests for AWS credentials +// ------------------------------------- +// The test suite will also run tests for AWS credentials. This works as +// follows. (Note prequisite setup is needed. This is documented in +// externalclient-setup.js). +// - iamcredentials:generateIdToken is used to generate a Google ID token using +// the service account access token. The service account client_id is used as +// audience. +// - AWS STS AssumeRoleWithWebIdentity API is used to exchange this token for +// temporary AWS security credentials for a specified AWS ARN role. +// - AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN +// environment variables are set using these credentials before the test is +// run simulating an AWS VM. +// - The test can now be run. +// +// For each test, a sample script is run in a child process with +// GOOGLE_APPLICATION_CREDENTIALS environment variable pointing to a temporary +// workload identity pool credentials configuration. A Cloud API is called in +// the process and the expected output is confirmed. + +const cp = require('child_process'); +const {assert} = require('chai'); +const {describe, it, before, afterEach} = require('mocha'); +const fs = require('fs'); +const {promisify} = require('util'); +const {GoogleAuth, DefaultTransporter} = require('google-auth-library'); +const os = require('os'); +const path = require('path'); +const http = require('http'); + +/** + * Runs the provided command using asynchronous child_process.exec. + * Unlike execSync, this works with another local HTTP server running in the + * background. + * @param {string} cmd The actual command string to run. + * @param {*} opts The optional parameters for child_process.exec. + * @return {Promise} A promise that resolves with a string + * corresponding with the terminal output. + */ +const execAsync = async (cmd, opts) => { + const {stdout, stderr} = await exec(cmd, opts); + return stdout + stderr; +}; + +/** + * Generates a Google ID token using the iamcredentials generateIdToken API. + * https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials#sa-credentials-oidc + * + * @param {GoogleAuth} auth The GoogleAuth instance. + * @param {string} aud The Google ID token audience. + * @param {string} clientEmail The service account client email. + * @return {Promise} A promise that resolves with the generated Google + * ID token. + */ +const generateGoogleIdToken = async (auth, aud, clientEmail) => { + // roles/iam.serviceAccountTokenCreator role needed. + const response = await auth.request({ + url: + 'https://iamcredentials.googleapis.com/v1/' + + `projects/-/serviceAccounts/${clientEmail}:generateIdToken`, + method: 'POST', + data: { + audience: aud, + includeEmail: true, + }, + }); + return response.data.token; +}; + +/** + * Rudimentary value lookup within an XML file by tagName. + * @param {string} rawXml The raw XML string. + * @param {string} tagName The name of the tag whose value is to be returned. + * @return {?string} The value if found, null otherwise. + */ +const getXmlValueByTagName = (rawXml, tagName) => { + const startIndex = rawXml.indexOf(`<${tagName}>`); + const endIndex = rawXml.indexOf(``, startIndex); + if (startIndex >= 0 && endIndex > startIndex) { + return rawXml.substring(startIndex + tagName.length + 2, endIndex); + } + return null; +}; + +/** + * Generates a Google OIDC ID token and exchanges it for AWS security credentials + * using the AWS STS AssumeRoleWithWebIdentity API. + * @param {GoogleAuth} auth The GoogleAuth instance. + * @param {string} aud The Google ID token audience. + * @param {string} clientEmail The service account client email. + * @param {string} awsRoleArn The Amazon Resource Name (ARN) of the role that + * the caller is assuming. + * @return {Promise} A promise that resolves with the generated AWS + * security credentials. + */ +const assumeRoleWithWebIdentity = async ( + auth, + aud, + clientEmail, + awsRoleArn +) => { + // API documented at: + // https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html + // Note that a role for web identity or OIDC federation will need to have + // been configured: + // https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html + const oidcToken = await generateGoogleIdToken(auth, aud, clientEmail); + const transporter = new DefaultTransporter(); + const url = + 'https://sts.amazonaws.com/?Action=AssumeRoleWithWebIdentity' + + '&Version=2011-06-15&DurationSeconds=3600&RoleSessionName=nodejs-test' + + `&RoleArn=${awsRoleArn}&WebIdentityToken=${oidcToken}`; + // The response is in XML format but we will parse it as text. + const response = await transporter.request({url, responseType: 'text'}); + const rawXml = response.data; + return { + awsAccessKeyId: getXmlValueByTagName(rawXml, 'AccessKeyId'), + awsSecretAccessKey: getXmlValueByTagName(rawXml, 'SecretAccessKey'), + awsSessionToken: getXmlValueByTagName(rawXml, 'SessionToken'), + }; +}; + +/** + * Generates a random string of the specified length, optionally using the + * specified alphabet. + * + * @param {number} length The length of the string to generate. + * @return {string} A random string of the provided length. + */ +const generateRandomString = length => { + const chars = []; + const allowedChars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + while (length > 0) { + chars.push( + allowedChars.charAt(Math.floor(Math.random() * allowedChars.length)) + ); + length--; + } + return chars.join(''); +}; + +////////////////////////////////////////////////////////////////////////// +// Copy values from the output of samples/scripts/externalclient-setup.js. +// OIDC provider STS audience. +const AUDIENCE_OIDC = + '//iam.googleapis.com/projects/1046198160504/locations/global/' + + 'workloadIdentityPools/pool-95vux39vzm/providers/oidc-95vux39vzm'; +// AWS provider STS audience. +const AUDIENCE_AWS = + '//iam.googleapis.com/projects/1046198160504/locations/global/' + + 'workloadIdentityPools/pool-95vux39vzm/providers/aws-95vux39vzm'; +// AWS ARN role used for federating from GCP to AWS via +// AssumeRoleWithWebIdentity. +const AWS_ROLE_ARN = 'arn:aws:iam::077071391996:role/ci-nodejs-test'; +////////////////////////////////////////////////////////////////////////// +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); +const unlink = promisify(fs.unlink); +const exec = promisify(cp.exec); +const keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; + +describe('samples for external-account', () => { + let httpServer; + let clientEmail; + let oidcToken; + let awsCredentials; + const port = 8088; + const suffix = generateRandomString(10); + const configFilePath = path.join(os.tmpdir(), `config-${suffix}.json`); + const oidcTokenFilePath = path.join(os.tmpdir(), `token-${suffix}.txt`); + const auth = new GoogleAuth({ + scopes: 'https://www.googleapis.com/auth/cloud-platform', + }); + + before(async () => { + const keys = JSON.parse(await readFile(keyFile, 'utf8')); + const clientId = keys.client_id; + clientEmail = keys.client_email; + + // Generate the Google OIDC token. This will be used as the external + // subject token for the following OIDC file-sourced and url-sourced + // credential tests. + oidcToken = await generateGoogleIdToken(auth, AUDIENCE_OIDC, clientEmail); + // Generate the AWS security keys. These will be used to similate an + // AWS VM to test external account AWS credentials. + awsCredentials = await assumeRoleWithWebIdentity( + auth, + clientId, + clientEmail, + AWS_ROLE_ARN + ); + }); + + afterEach(async () => { + // Delete temporary files. + if (fs.existsSync(configFilePath)) { + await unlink(configFilePath); + } + if (fs.existsSync(oidcTokenFilePath)) { + await unlink(oidcTokenFilePath); + } + // Close any open http servers. + if (httpServer) { + httpServer.close(); + } + }); + + it('should acquire ADC for file-sourced creds', async () => { + // Create file-sourced configuration JSON file. + // The created OIDC token will be used as the subject token and will be + // retrieved from a file location. + const config = { + type: 'external_account', + audience: AUDIENCE_OIDC, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1beta/token', + service_account_impersonation_url: + 'https://iamcredentials.googleapis.com/v1/projects/' + + `-/serviceAccounts/${clientEmail}:generateAccessToken`, + credential_source: { + file: oidcTokenFilePath, + }, + }; + await writeFile(oidcTokenFilePath, oidcToken); + await writeFile(configFilePath, JSON.stringify(config)); + + // Run sample script with GOOGLE_APPLICATION_CREDENTIALS envvar + // pointing to the temporarily created configuration file. + const output = await execAsync(`${process.execPath} adc`, { + env: { + ...process.env, + GOOGLE_APPLICATION_CREDENTIALS: configFilePath, + }, + }); + // Confirm expected script output. + assert.match(output, /DNS Info:/); + }); + + it('should acquire ADC for url-sourced creds', async () => { + // Create url-sourced configuration JSON file. + // The created OIDC token will be used as the subject token and will be + // retrieved from a local server. + const config = { + type: 'external_account', + audience: AUDIENCE_OIDC, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1beta/token', + service_account_impersonation_url: + 'https://iamcredentials.googleapis.com/v1/projects/' + + `-/serviceAccounts/${clientEmail}:generateAccessToken`, + credential_source: { + url: `http://localhost:${port}/token`, + headers: { + 'my-header': 'some-value', + }, + format: { + type: 'json', + subject_token_field_name: 'access_token', + }, + }, + }; + await writeFile(configFilePath, JSON.stringify(config)); + // Start local metadata server. This will expose a /token + // endpoint to return the OIDC token in JSON format. + httpServer = http.createServer((req, res) => { + if (req.url === '/token' && req.method === 'GET') { + // Confirm expected header is passed along the request. + if (req.headers['my-header'] === 'some-value') { + res.setHeader('Content-Type', 'application/json'); + res.writeHead(200); + res.end( + JSON.stringify({ + access_token: oidcToken, + }) + ); + } else { + res.setHeader('Content-Type', 'application/json'); + res.writeHead(400); + res.end( + JSON.stringify({ + error: 'missing-header', + }) + ); + } + } else { + res.writeHead(404); + res.end(JSON.stringify({error: 'Resource not found'})); + } + }); + await new Promise(resolve => { + httpServer.listen(port, resolve); + }); + + // Run sample script with GOOGLE_APPLICATION_CREDENTIALS environment + // variable pointing to the temporarily created configuration file. + const output = await execAsync(`${process.execPath} adc`, { + env: { + ...process.env, + GOOGLE_APPLICATION_CREDENTIALS: configFilePath, + }, + }); + // Confirm expected script output. + assert.match(output, /DNS Info:/); + }); + + it('should acquire ADC for AWS creds', async () => { + // Create AWS configuration JSON file. + const config = { + type: 'external_account', + audience: AUDIENCE_AWS, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: 'https://sts.googleapis.com/v1beta/token', + service_account_impersonation_url: + 'https://iamcredentials.googleapis.com/v1/projects/' + + `-/serviceAccounts/${clientEmail}:generateAccessToken`, + credential_source: { + environment_id: 'aws1', + regional_cred_verification_url: + 'https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15', + }, + }; + await writeFile(configFilePath, JSON.stringify(config)); + + // Run sample script with GOOGLE_APPLICATION_CREDENTIALS environment + // variable pointing to the temporarily created configuration file. + // Populate AWS environment variables to simulate an AWS VM. + const output = await execAsync(`${process.execPath} adc`, { + env: { + ...process.env, + // AWS environment variables: hardcoded region + AWS security + // credentials. + AWS_REGION: 'us-east-2', + AWS_ACCESS_KEY_ID: awsCredentials.awsAccessKeyId, + AWS_SECRET_ACCESS_KEY: awsCredentials.awsSecretAccessKey, + AWS_SESSION_TOKEN: awsCredentials.awsSessionToken, + // GOOGLE_APPLICATION_CREDENTIALS environment variable used for ADC. + GOOGLE_APPLICATION_CREDENTIALS: configFilePath, + }, + }); + // Confirm expected script output. + assert.match(output, /DNS Info:/); + }); +}); diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index f7d4d13c..bc1dea8b 100644 --- a/src/auth/authclient.ts +++ b/src/auth/authclient.ts @@ -13,26 +13,112 @@ // limitations under the License. import {EventEmitter} from 'events'; -import {GaxiosOptions, GaxiosPromise} from 'gaxios'; +import {GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; import {DefaultTransporter} from '../transporters'; import {Credentials} from './credentials'; import {Headers} from './oauth2client'; +/** + * Defines the root interface for all clients that generate credentials + * for calling Google APIs. All clients should implement this interface. + */ +export interface CredentialsClient { + /** + * The project ID corresponding to the current credentials if available. + */ + projectId?: string | null; + + /** + * The expiration threshold in milliseconds before forcing token refresh. + */ + eagerRefreshThresholdMillis: number; + + /** + * Whether to force refresh on failure when making an authorization request. + */ + forceRefreshOnFailure: boolean; + + /** + * @return A promise that resolves with the current GCP access token + * response. If the current credential is expired, a new one is retrieved. + */ + getAccessToken(): Promise<{ + token?: string | null; + res?: GaxiosResponse | null; + }>; + + /** + * The main authentication interface. It takes an optional url which when + * present is the endpoint being accessed, and returns a Promise which + * resolves with authorization header fields. + * + * The result has the form: + * { Authorization: 'Bearer ' } + * @param url The URI being authorized. + */ + getRequestHeaders(url?: string): Promise; + + /** + * Provides an alternative Gaxios request implementation with auth credentials + */ + request(opts: GaxiosOptions): GaxiosPromise; + + /** + * Sets the auth credentials. + */ + setCredentials(credentials: Credentials): void; + + /** + * Subscribes a listener to the tokens event triggered when a token is + * generated. + * + * @param event The tokens event to subscribe to. + * @param listener The listener that triggers on event trigger. + * @return The current client instance. + */ + on(event: 'tokens', listener: (tokens: Credentials) => void): this; +} + export declare interface AuthClient { on(event: 'tokens', listener: (tokens: Credentials) => void): this; } -export abstract class AuthClient extends EventEmitter { +export abstract class AuthClient + extends EventEmitter + implements CredentialsClient { protected quotaProjectId?: string; transporter = new DefaultTransporter(); credentials: Credentials = {}; + projectId?: string | null; + eagerRefreshThresholdMillis = 5 * 60 * 1000; + forceRefreshOnFailure = false; /** * Provides an alternative Gaxios request implementation with auth credentials */ abstract request(opts: GaxiosOptions): GaxiosPromise; + /** + * The main authentication interface. It takes an optional url which when + * present is the endpoint being accessed, and returns a Promise which + * resolves with authorization header fields. + * + * The result has the form: + * { Authorization: 'Bearer ' } + * @param url The URI being authorized. + */ + abstract getRequestHeaders(url?: string): Promise; + + /** + * @return A promise that resolves with the current GCP access token + * response. If the current credential is expired, a new one is retrieved. + */ + abstract getAccessToken(): Promise<{ + token?: string | null; + res?: GaxiosResponse | null; + }>; + /** * Sets the auth credentials. */ diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts new file mode 100644 index 00000000..6b873488 --- /dev/null +++ b/src/auth/awsclient.ts @@ -0,0 +1,259 @@ +// Copyright 2021 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 {GaxiosOptions} from 'gaxios'; + +import {AwsRequestSigner} from './awsrequestsigner'; +import { + BaseExternalAccountClient, + BaseExternalAccountClientOptions, +} from './baseexternalclient'; +import {RefreshOptions} from './oauth2client'; + +/** + * AWS credentials JSON interface. This is used for AWS workloads. + */ +export interface AwsClientOptions extends BaseExternalAccountClientOptions { + credential_source: { + environment_id: string; + // Region can also be determined from the AWS_REGION environment variable. + 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. + url?: string; + regional_cred_verification_url: string; + }; +} + +/** + * Interface defining the AWS security-credentials endpoint response. + */ +interface AwsSecurityCredentials { + Code: string; + LastUpdated: string; + Type: string; + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + Expiration: string; +} + +/** + * AWS external account client. This is used for AWS workloads, where + * AWS STS GetCallerIdentity serialized signed requests are exchanged for + * GCP access token. + */ +export class AwsClient extends BaseExternalAccountClient { + private readonly environmentId: string; + private readonly regionUrl?: string; + private readonly securityCredentialsUrl?: string; + private readonly regionalCredVerificationUrl: string; + private awsRequestSigner: AwsRequestSigner | null; + private region: string; + + /** + * Instantiates an AwsClient instance using the provided JSON + * object loaded from an external account credentials file. + * An error is thrown if the credential is not a valid AWS credential. + * @param options The external account options object typically loaded + * from the external account JSON credential file. + * @param additionalOptions Optional additional behavior customization + * options. These currently customize expiration threshold time and + * whether to retry on 401/403 API request errors. + */ + constructor(options: AwsClientOptions, additionalOptions?: RefreshOptions) { + 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 environment variable + this.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; + this.regionalCredVerificationUrl = + options.credential_source.regional_cred_verification_url; + const match = this.environmentId?.match(/^(aws)(\d+)$/); + if (!match || !this.regionalCredVerificationUrl) { + throw new Error('No valid AWS "credential_source" provided'); + } else if (parseInt(match[2], 10) !== 1) { + throw new Error( + `aws version "${match[2]}" is not supported in the current build.` + ); + } + this.awsRequestSigner = null; + this.region = ''; + } + + /** + * 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. Retrieve AWS region from availability-zone. + * 2a. Check AWS credentials in environment variables. If not found, get + * from security-credentials endpoint. + * 2b. 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 + * 3. Generate the signed request to AWS STS GetCallerIdentity action. + * 4. Inject x-goog-cloud-target-resource into header and serialize the + * signed request. This will be the subject-token to pass to GCP STS. + * @return A promise that resolves with the external subject token. + */ + async retrieveSubjectToken(): Promise { + // Initialize AWS request signer if not already initialized. + if (!this.awsRequestSigner) { + this.region = await this.getAwsRegion(); + 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 ( + 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']!, + // This is normally not available for permanent credentials. + token: process.env['AWS_SESSION_TOKEN'], + }; + } + // Since the role on a VM can change, we don't need to cache it. + const roleName = await this.getAwsRoleName(); + // 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); + return { + accessKeyId: awsCreds.AccessKeyId, + secretAccessKey: awsCreds.SecretAccessKey, + token: awsCreds.Token, + }; + }, this.region); + } + + // Generate signed request to AWS STS GetCallerIdentity API. + // Use the required regional endpoint. Otherwise, the request will fail. + const options = await this.awsRequestSigner.getRequestOptions({ + url: this.regionalCredVerificationUrl.replace('{region}', this.region), + method: 'POST', + }); + // The GCP STS endpoint expects the headers to be formatted as: + // [ + // {key: 'x-amz-date', value: '...'}, + // {key: 'Authorization', value: '...'}, + // ... + // ] + // And then serialized as: + // encodeURIComponent(JSON.stringify({ + // url: '...', + // method: 'POST', + // headers: [{key: 'x-amz-date', value: '...'}, ...] + // })) + const reformattedHeader: {key: string; value: string}[] = []; + const extendedHeaders = Object.assign( + { + // The full, canonical resource name of the workload identity pool + // provider, with or without the HTTPS prefix. + // Including this header as part of the signature is recommended to + // ensure data integrity. + 'x-goog-cloud-target-resource': this.audience, + }, + options.headers + ); + // Reformat header to GCP STS expected format. + for (const key in extendedHeaders) { + reformattedHeader.push({ + key, + value: extendedHeaders[key], + }); + } + // Serialize the reformatted signed request. + return encodeURIComponent( + JSON.stringify({ + url: options.url, + method: options.method, + headers: reformattedHeader, + }) + ); + } + + /** + * @return A promise that resolves with the current AWS region. + */ + private async getAwsRegion(): Promise { + if (process.env['AWS_REGION']) { + return process.env['AWS_REGION']; + } + 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', + }; + 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); + } + + /** + * @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(): 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', + }; + 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. + * @return A promise that resolves with the temporary AWS credentials + * needed for creating the GetCallerIdentity signed request. + */ + private async getAwsSecurityCredentials( + roleName: string + ): Promise { + const response = await this.transporter.request({ + url: `${this.securityCredentialsUrl}/${roleName}`, + responseType: 'json', + }); + return response.data; + } +} diff --git a/src/auth/awsrequestsigner.ts b/src/auth/awsrequestsigner.ts new file mode 100644 index 00000000..a0e8870c --- /dev/null +++ b/src/auth/awsrequestsigner.ts @@ -0,0 +1,305 @@ +// Copyright 2021 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 {GaxiosOptions} from 'gaxios'; + +import {Headers} from './oauth2client'; +import {Crypto, createCrypto, fromArrayBufferToHex} from '../crypto/crypto'; + +type HttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'HEAD' + | 'DELETE' + | 'CONNECT' + | 'OPTIONS' + | 'TRACE'; + +/** Interface defining the AWS authorization header map for signed requests. */ +interface AwsAuthHeaderMap { + amzDate?: string; + authorizationHeader: string; + canonicalQuerystring: string; +} + +/** + * Interface defining AWS security credentials. + * These are either determined from AWS security_credentials endpoint or + * AWS environment variables. + */ +interface AwsSecurityCredentials { + accessKeyId: string; + secretAccessKey: string; + token?: string; +} + +/** + * Interface defining the parameters needed to compute the AWS + * authentication header map. + */ +interface GenerateAuthHeaderMapOptions { + // The crypto instance used to facilitate cryptographic operations. + crypto: Crypto; + // The AWS service URL hostname. + host: string; + // The AWS service URL path name. + canonicalUri: string; + // The AWS service URL query string. + canonicalQuerystring: string; + // The HTTP method used to call this API. + method: HttpMethod; + // The AWS region. + region: string; + // The AWS security credentials. + securityCredentials: AwsSecurityCredentials; + // The optional request payload if available. + requestPayload?: string; + // The optional additional headers needed for the requested AWS API. + additionalAmzHeaders?: Headers; +} + +/** AWS Signature Version 4 signing algorithm identifier. */ +const AWS_ALGORITHM = 'AWS4-HMAC-SHA256'; +/** + * The termination string for the AWS credential scope value as defined in + * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + */ +const AWS_REQUEST_TYPE = 'aws4_request'; + +/** + * Implements an AWS API request signer based on the AWS Signature Version 4 + * signing process. + * https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html + */ +export class AwsRequestSigner { + private readonly crypto: Crypto; + + /** + * Instantiates an AWS API request signer used to send authenticated signed + * requests to AWS APIs based on the AWS Signature Version 4 signing process. + * This also provides a mechanism to generate the signed request without + * sending it. + * @param getCredentials A mechanism to retrieve AWS security credentials + * when needed. + * @param region The AWS region to use. + */ + constructor( + private readonly getCredentials: () => Promise, + private readonly region: string + ) { + this.crypto = createCrypto(); + } + + /** + * Generates the signed request for the provided HTTP request for calling + * an AWS API. This follows the steps described at: + * https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html + * @param amzOptions The AWS request options that need to be signed. + * @return A promise that resolves with the GaxiosOptions containing the + * signed HTTP request parameters. + */ + async getRequestOptions(amzOptions: GaxiosOptions): Promise { + if (!amzOptions.url) { + throw new Error('"url" is required in "amzOptions"'); + } + // Stringify JSON requests. This will be set in the request body of the + // generated signed request. + const requestPayloadData = + typeof amzOptions.data === 'object' + ? JSON.stringify(amzOptions.data) + : amzOptions.data; + const url = amzOptions.url; + const method = amzOptions.method || 'GET'; + const requestPayload = amzOptions.body || requestPayloadData; + const additionalAmzHeaders = amzOptions.headers; + const awsSecurityCredentials = await this.getCredentials(); + const uri = new URL(url); + const headerMap = await generateAuthenticationHeaderMap({ + crypto: this.crypto, + host: uri.host, + canonicalUri: uri.pathname, + canonicalQuerystring: uri.search.substr(1), + method, + region: this.region, + securityCredentials: awsSecurityCredentials, + requestPayload, + additionalAmzHeaders, + }); + // Append additional optional headers, eg. X-Amz-Target, Content-Type, etc. + const headers: {[key: string]: string} = Object.assign( + // Add x-amz-date if available. + headerMap.amzDate ? {'x-amz-date': headerMap.amzDate} : {}, + { + Authorization: headerMap.authorizationHeader, + host: uri.host, + }, + additionalAmzHeaders || {} + ); + if (awsSecurityCredentials.token) { + Object.assign(headers, { + 'x-amz-security-token': awsSecurityCredentials.token, + }); + } + const awsSignedReq: GaxiosOptions = { + url, + method: method, + headers, + }; + + if (typeof requestPayload !== 'undefined') { + awsSignedReq.body = requestPayload; + } + + return awsSignedReq; + } +} + +/** + * Creates the HMAC-SHA256 hash of the provided message using the + * provided key. + * + * @param crypto The crypto instance used to facilitate cryptographic + * operations. + * @param key The HMAC-SHA256 key to use. + * @param msg The message to hash. + * @return The computed hash bytes. + */ +async function sign( + crypto: Crypto, + key: string | ArrayBuffer, + msg: string +): Promise { + return await crypto.signWithHmacSha256(key, msg); +} + +/** + * Calculates the signing key used to calculate the signature for + * AWS Signature Version 4 based on: + * https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + * + * @param crypto The crypto instance used to facilitate cryptographic + * operations. + * @param key The AWS secret access key. + * @param dateStamp The '%Y%m%d' date format. + * @param region The AWS region. + * @param serviceName The AWS service name, eg. sts. + * @return The signing key bytes. + */ +async function getSigningKey( + crypto: Crypto, + key: string, + dateStamp: string, + region: string, + serviceName: string +): Promise { + const kDate = await sign(crypto, `AWS4${key}`, dateStamp); + const kRegion = await sign(crypto, kDate, region); + const kService = await sign(crypto, kRegion, serviceName); + const kSigning = await sign(crypto, kService, 'aws4_request'); + return kSigning; +} + +/** + * Generates the authentication header map needed for generating the AWS + * Signature Version 4 signed request. + * + * @param option The options needed to compute the authentication header map. + * @return The AWS authentication header map which constitutes of the following + * components: amz-date, authorization header and canonical query string. + */ +async function generateAuthenticationHeaderMap( + options: GenerateAuthHeaderMapOptions +): Promise { + const additionalAmzHeaders = options.additionalAmzHeaders || {}; + const requestPayload = options.requestPayload || ''; + // iam.amazonaws.com host => iam service. + // sts.us-east-2.amazonaws.com => sts service. + const serviceName = options.host.split('.')[0]; + const now = new Date(); + // Format: '%Y%m%dT%H%M%SZ'. + const amzDate = now + .toISOString() + .replace(/[-:]/g, '') + .replace(/\.[0-9]+/, ''); + // Format: '%Y%m%d'. + const dateStamp = now.toISOString().replace(/[-]/g, '').replace(/T.*/, ''); + + // Change all additional headers to be lower case. + const reformattedAdditionalAmzHeaders: Headers = {}; + Object.keys(additionalAmzHeaders).forEach(key => { + reformattedAdditionalAmzHeaders[key.toLowerCase()] = + additionalAmzHeaders[key]; + }); + // Add AWS token if available. + if (options.securityCredentials.token) { + reformattedAdditionalAmzHeaders['x-amz-security-token'] = + options.securityCredentials.token; + } + // Header keys need to be sorted alphabetically. + const amzHeaders = Object.assign( + { + host: options.host, + }, + // Previously the date was not fixed with x-amz- and could be provided manually. + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req + reformattedAdditionalAmzHeaders.date ? {} : {'x-amz-date': amzDate}, + reformattedAdditionalAmzHeaders + ); + let canonicalHeaders = ''; + const signedHeadersList = Object.keys(amzHeaders).sort(); + signedHeadersList.forEach(key => { + canonicalHeaders += `${key}:${amzHeaders[key]}\n`; + }); + const signedHeaders = signedHeadersList.join(';'); + + const payloadHash = await options.crypto.sha256DigestHex(requestPayload); + // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + const canonicalRequest = + `${options.method}\n` + + `${options.canonicalUri}\n` + + `${options.canonicalQuerystring}\n` + + `${canonicalHeaders}\n` + + `${signedHeaders}\n` + + `${payloadHash}`; + const credentialScope = `${dateStamp}/${options.region}/${serviceName}/${AWS_REQUEST_TYPE}`; + // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + const stringToSign = + `${AWS_ALGORITHM}\n` + + `${amzDate}\n` + + `${credentialScope}\n` + + (await options.crypto.sha256DigestHex(canonicalRequest)); + // https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + const signingKey = await getSigningKey( + options.crypto, + options.securityCredentials.secretAccessKey, + dateStamp, + options.region, + serviceName + ); + const signature = await sign(options.crypto, signingKey, stringToSign); + // https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html + const authorizationHeader = + `${AWS_ALGORITHM} Credential=${options.securityCredentials.accessKeyId}/` + + `${credentialScope}, SignedHeaders=${signedHeaders}, ` + + `Signature=${fromArrayBufferToHex(signature)}`; + + return { + // Do not return x-amz-date if date is available. + amzDate: reformattedAdditionalAmzHeaders.date ? undefined : amzDate, + authorizationHeader, + canonicalQuerystring: options.canonicalQuerystring, + }; +} diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts new file mode 100644 index 00000000..2d0aa10c --- /dev/null +++ b/src/auth/baseexternalclient.ts @@ -0,0 +1,487 @@ +// Copyright 2021 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 { + GaxiosError, + GaxiosOptions, + GaxiosPromise, + GaxiosResponse, +} from 'gaxios'; +import * as stream from 'stream'; + +import {Credentials} from './credentials'; +import {AuthClient} from './authclient'; +import {BodyResponseCallback} from '../transporters'; +import {GetAccessTokenResponse, Headers, RefreshOptions} from './oauth2client'; +import * as sts from './stscredentials'; +import {ClientAuthentication} from './oauth2common'; + +/** + * The required token exchange grant_type: rfc8693#section-2.1 + */ +const STS_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +/** + * The requested token exchange requested_token_type: rfc8693#section-2.1 + */ +const STS_REQUEST_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +/** The default OAuth scope to request when none is provided. */ +const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; +/** + * Offset to take into account network delays and server clock skews. + */ +export const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000; +/** + * The credentials JSON file type for external account clients. + * There are 3 types of JSON configs: + * 1. authorized_user => Google end user credential + * 2. service_account => Google service account credential + * 3. external_Account => non-GCP service (eg. AWS, Azure, K8s) + */ +export const EXTERNAL_ACCOUNT_TYPE = 'external_account'; +/** Cloud resource manager URL used to retrieve project information. */ +export const CLOUD_RESOURCE_MANAGER = + 'https://cloudresourcemanager.googleapis.com/v1/projects/'; + +/** + * Base external account credentials json interface. + */ +export interface BaseExternalAccountClientOptions { + type: string; + audience: string; + subject_token_type: string; + service_account_impersonation_url?: string; + token_url: string; + token_info_url?: string; + client_id?: string; + client_secret?: string; + quota_project_id?: string; +} + +/** + * Interface defining the successful response for iamcredentials + * generateAccessToken API. + * https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken + */ +export interface IamGenerateAccessTokenResponse { + accessToken: string; + // ISO format used for expiration time: 2014-10-02T15:01:23.045123456Z + expireTime: string; +} + +/** + * Interface defining the project information response returned by the cloud + * resource manager. + * https://cloud.google.com/resource-manager/reference/rest/v1/projects#Project + */ +export interface ProjectInfo { + projectNumber: string; + projectId: string; + lifecycleState: string; + name: string; + createTime?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parent: {[key: string]: any}; +} + +/** + * Internal interface for tracking the access token expiration time. + */ +interface CredentialsWithResponse extends Credentials { + res?: GaxiosResponse | null; +} + +/** + * Base external account client. This is used to instantiate AuthClients for + * exchanging external account credentials for GCP access token and authorizing + * requests to GCP APIs. + * The base class implements common logic for exchanging various type of + * external credentials for GCP access token. The logic of determining and + * retrieving the external credential based on the environment and + * credential_source will be left for the subclasses. + */ +export abstract class BaseExternalAccountClient extends AuthClient { + /** + * OAuth scopes for the GCP access token to use. When not provided, + * the default https://www.googleapis.com/auth/cloud-platform is + * used. + */ + public scopes?: string | string[]; + private cachedAccessToken: CredentialsWithResponse | null; + protected readonly audience: string; + private readonly subjectTokenType: string; + private readonly serviceAccountImpersonationUrl?: string; + private readonly stsCredential: sts.StsCredentials; + public projectId: string | null; + public projectNumber: string | null; + public readonly eagerRefreshThresholdMillis: number; + public readonly forceRefreshOnFailure: boolean; + + /** + * Instantiate a BaseExternalAccountClient instance using the provided JSON + * object loaded from an external account credentials file. + * @param options The external account options object typically loaded + * from the external account JSON credential file. + * @param additionalOptions Optional additional behavior customization + * options. These currently customize expiration threshold time and + * whether to retry on 401/403 API request errors. + */ + constructor( + options: BaseExternalAccountClientOptions, + additionalOptions?: RefreshOptions + ) { + super(); + if (options.type !== EXTERNAL_ACCOUNT_TYPE) { + throw new Error( + `Expected "${EXTERNAL_ACCOUNT_TYPE}" type but ` + + `received "${options.type}"` + ); + } + const clientAuth = options.client_id + ? ({ + confidentialClientType: 'basic', + clientId: options.client_id, + clientSecret: options.client_secret, + } as ClientAuthentication) + : undefined; + this.stsCredential = new sts.StsCredentials(options.token_url, clientAuth); + // Default OAuth scope. This could be overridden via public property. + this.scopes = [DEFAULT_OAUTH_SCOPE]; + this.cachedAccessToken = null; + this.audience = options.audience; + this.subjectTokenType = options.subject_token_type; + this.quotaProjectId = options.quota_project_id; + this.serviceAccountImpersonationUrl = + options.service_account_impersonation_url; + // As threshold could be zero, + // eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the + // zero value. + if (typeof additionalOptions?.eagerRefreshThresholdMillis !== 'number') { + this.eagerRefreshThresholdMillis = EXPIRATION_TIME_OFFSET; + } else { + this.eagerRefreshThresholdMillis = additionalOptions! + .eagerRefreshThresholdMillis as number; + } + this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; + this.projectId = null; + this.projectNumber = this.getProjectNumber(this.audience); + } + + /** + * Provides a mechanism to inject GCP access tokens directly. + * When the provided credential expires, a new credential, using the + * external account options, is retrieved. + * @param credentials The Credentials object to set on the current client. + */ + setCredentials(credentials: Credentials) { + super.setCredentials(credentials); + this.cachedAccessToken = credentials; + } + + /** + * Triggered when a external subject token is needed to be exchanged for a GCP + * access token via GCP STS endpoint. + * This abstract method needs to be implemented by subclasses depending on + * the type of external credential used. + * @return A promise that resolves with the external subject token. + */ + abstract async retrieveSubjectToken(): Promise; + + /** + * @return A promise that resolves with the current GCP access token + * response. If the current credential is expired, a new one is retrieved. + */ + async getAccessToken(): Promise { + // If cached access token is unavailable or expired, force refresh. + if (!this.cachedAccessToken || this.isExpired(this.cachedAccessToken)) { + await this.refreshAccessTokenAsync(); + } + // Return GCP access token in GetAccessTokenResponse format. + return { + token: this.cachedAccessToken!.access_token, + res: this.cachedAccessToken!.res, + }; + } + + /** + * The main authentication interface. It takes an optional url which when + * present is the endpoint> being accessed, and returns a Promise which + * resolves with authorization header fields. + * + * The result has the form: + * { Authorization: 'Bearer ' } + */ + async getRequestHeaders(): Promise { + const accessTokenResponse = await this.getAccessToken(); + const headers: Headers = { + Authorization: `Bearer ${accessTokenResponse.token}`, + }; + return this.addSharedMetadataHeaders(headers); + } + + /** + * Provides a request implementation with OAuth 2.0 flow. In cases of + * HTTP 401 and 403 responses, it automatically asks for a new access token + * and replays the unsuccessful request. + * @param opts Request options. + * @param callback callback. + * @return A promise that resolves with the HTTP response when no callback is + * provided. + */ + request(opts: GaxiosOptions): GaxiosPromise; + request(opts: GaxiosOptions, callback: BodyResponseCallback): void; + request( + opts: GaxiosOptions, + callback?: BodyResponseCallback + ): GaxiosPromise | void { + if (callback) { + this.requestAsync(opts).then( + r => callback(null, r), + e => { + return callback(e, e.response); + } + ); + } else { + return this.requestAsync(opts); + } + } + + /** + * @return A promise that resolves with the project ID corresponding to the + * current workload identity pool. When not determinable, this resolves with + * null. + * This is introduced to match the current pattern of using the Auth + * library: + * const projectId = await auth.getProjectId(); + * const url = `https://dns.googleapis.com/dns/v1/projects/${projectId}`; + * const res = await client.request({ url }); + * The resource may not have permission + * (resourcemanager.projects.get) to call this API or the required + * scopes may not be selected: + * https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes + */ + async getProjectId(): Promise { + if (this.projectId) { + // Return previously determined project ID. + return this.projectId; + } else if (this.projectNumber) { + // Preferable not to use request() to avoid retrial policies. + const headers = await this.getRequestHeaders(); + const response = await this.transporter.request({ + headers, + url: `${CLOUD_RESOURCE_MANAGER}${this.projectNumber}`, + responseType: 'json', + }); + this.projectId = response.data.projectId; + return this.projectId; + } + return null; + } + + /** + * Authenticates the provided HTTP request, processes it and resolves with the + * returned response. + * @param opts The HTTP request options. + * @param retry Whether the current attempt is a retry after a failed attempt. + * @return A promise that resolves with the successful response. + */ + protected async requestAsync( + opts: GaxiosOptions, + retry = false + ): Promise> { + let response: GaxiosResponse; + try { + const requestHeaders = await this.getRequestHeaders(); + opts.headers = opts.headers || {}; + if (requestHeaders && requestHeaders['x-goog-user-project']) { + opts.headers['x-goog-user-project'] = + requestHeaders['x-goog-user-project']; + } + if (requestHeaders && requestHeaders.Authorization) { + opts.headers.Authorization = requestHeaders.Authorization; + } + response = await this.transporter.request(opts); + } catch (e) { + const res = (e as GaxiosError).response; + if (res) { + const statusCode = res.status; + // Retry the request for metadata if the following criteria are true: + // - We haven't already retried. It only makes sense to retry once. + // - The response was a 401 or a 403 + // - The request didn't send a readableStream + // - forceRefreshOnFailure is true + const isReadableStream = res.config.data instanceof stream.Readable; + const isAuthErr = statusCode === 401 || statusCode === 403; + if ( + !retry && + isAuthErr && + !isReadableStream && + this.forceRefreshOnFailure + ) { + await this.refreshAccessTokenAsync(); + return await this.requestAsync(opts, true); + } + } + throw e; + } + return response; + } + + /** + * Forces token refresh, even if unexpired tokens are currently cached. + * External credentials are exchanged for GCP access tokens via the token + * exchange endpoint and other settings provided in the client options + * object. + * If the service_account_impersonation_url is provided, an additional + * step to exchange the external account GCP access token for a service + * account impersonated token is performed. + * @return A promise that resolves with the fresh GCP access tokens. + */ + protected async refreshAccessTokenAsync(): Promise { + // Retrieve the external credential. + const subjectToken = await this.retrieveSubjectToken(); + // Construct the STS credentials options. + const stsCredentialsOptions: sts.StsCredentialsOptions = { + grantType: STS_GRANT_TYPE, + audience: this.audience, + requestedTokenType: STS_REQUEST_TOKEN_TYPE, + subjectToken, + subjectTokenType: this.subjectTokenType, + // generateAccessToken requires the provided access token to have + // scopes: + // https://www.googleapis.com/auth/iam or + // https://www.googleapis.com/auth/cloud-platform + // The new service account access token scopes will match the user + // provided ones. + scope: this.serviceAccountImpersonationUrl + ? [DEFAULT_OAUTH_SCOPE] + : this.getScopesArray(), + }; + + // Exchange the external credentials for a GCP access token. + const stsResponse = await this.stsCredential.exchangeToken( + stsCredentialsOptions + ); + + if (this.serviceAccountImpersonationUrl) { + this.cachedAccessToken = await this.getImpersonatedAccessToken( + stsResponse.access_token + ); + } else { + // Save response in cached access token. + this.cachedAccessToken = { + access_token: stsResponse.access_token, + expiry_date: new Date().getTime() + stsResponse.expires_in * 1000, + res: stsResponse.res, + }; + } + + // Save credentials. + this.credentials = {}; + Object.assign(this.credentials, this.cachedAccessToken); + delete (this.credentials as CredentialsWithResponse).res; + + // Trigger tokens event to notify external listeners. + this.emit('tokens', { + refresh_token: null, + expiry_date: this.cachedAccessToken!.expiry_date, + access_token: this.cachedAccessToken!.access_token, + token_type: 'Bearer', + id_token: null, + }); + // Return the cached access token. + return this.cachedAccessToken; + } + + /** + * Returns the workload identity pool project number if it is determinable + * from the audience resource name. + * @param audience The STS audience used to determine the project number. + * @return The project number associated with the workload identity pool, if + * this can be determined from the STS audience field. Otherwise, null is + * returned. + */ + private getProjectNumber(audience: string): string | null { + // STS audience pattern: + // //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/... + const match = audience.match(/\/projects\/([^/]+)/); + if (!match) { + return null; + } + return match[1]; + } + + /** + * Exchanges an external account GCP access token for a service + * account impersonated access token using iamcredentials + * GenerateAccessToken API. + * @param token The access token to exchange for a service account access + * token. + * @return A promise that resolves with the service account impersonated + * credentials response. + */ + private async getImpersonatedAccessToken( + token: string + ): Promise { + const opts: GaxiosOptions = { + url: this.serviceAccountImpersonationUrl!, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + data: { + scope: this.getScopesArray(), + }, + responseType: 'json', + }; + const response = await this.transporter.request( + opts + ); + const successResponse = response.data; + return { + access_token: successResponse.accessToken, + // Convert from ISO format to timestamp. + expiry_date: new Date(successResponse.expireTime).getTime(), + res: response, + }; + } + + /** + * Returns whether the provided credentials are expired or not. + * If there is no expiry time, assumes the token is not expired or expiring. + * @param accessToken The credentials to check for expiration. + * @return Whether the credentials are expired or not. + */ + private isExpired(accessToken: Credentials): boolean { + const now = new Date().getTime(); + return accessToken.expiry_date + ? now >= accessToken.expiry_date - this.eagerRefreshThresholdMillis + : false; + } + + /** + * @return The list of scopes for the requested GCP access token. + */ + private getScopesArray(): string[] { + // Since scopes can be provided as string or array, the type should + // be normalized. + if (typeof this.scopes === 'string') { + return [this.scopes]; + } else if (typeof this.scopes === 'undefined') { + return [DEFAULT_OAUTH_SCOPE]; + } else { + return this.scopes; + } + } +} diff --git a/src/auth/externalclient.ts b/src/auth/externalclient.ts new file mode 100644 index 00000000..191c6953 --- /dev/null +++ b/src/auth/externalclient.ts @@ -0,0 +1,80 @@ +// Copyright 2021 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 {RefreshOptions} from './oauth2client'; +import { + BaseExternalAccountClient, + // This is the identifier in the JSON config for the type of credential. + // This string constant indicates that an external account client should be + // instantiated. + // There are 3 types of JSON configs: + // 1. authorized_user => Google end user credential + // 2. service_account => Google service account credential + // 3. external_Account => non-GCP service (eg. AWS, Azure, K8s) + EXTERNAL_ACCOUNT_TYPE, +} from './baseexternalclient'; +import { + IdentityPoolClient, + IdentityPoolClientOptions, +} from './identitypoolclient'; +import {AwsClient, AwsClientOptions} from './awsclient'; + +export type ExternalAccountClientOptions = + | IdentityPoolClientOptions + | AwsClientOptions; + +/** + * Dummy class with no constructor. Developers are expected to use fromJSON. + */ +export class ExternalAccountClient { + constructor() { + throw new Error( + 'ExternalAccountClients should be initialized via: ' + + 'ExternalAccountClient.fromJSON(), ' + + 'directly via explicit constructors, eg. ' + + 'new AwsClient(options), new IdentityPoolClient(options) or via ' + + 'new GoogleAuth(options).getClient()' + ); + } + + /** + * This static method will instantiate the + * corresponding type of external account credential depending on the + * underlying credential source. + * @param options The external account options object typically loaded + * from the external account JSON credential file. + * @param additionalOptions Optional additional behavior customization + * options. These currently customize expiration threshold time and + * whether to retry on 401/403 API request errors. + * @return A BaseExternalAccountClient instance or null if the options + * provided do not correspond to an external account credential. + */ + static fromJSON( + options: ExternalAccountClientOptions, + additionalOptions?: RefreshOptions + ): BaseExternalAccountClient | null { + if (options && options.type === EXTERNAL_ACCOUNT_TYPE) { + if ((options as AwsClientOptions).credential_source?.environment_id) { + return new AwsClient(options as AwsClientOptions, additionalOptions); + } else { + return new IdentityPoolClient( + options as IdentityPoolClientOptions, + additionalOptions + ); + } + } else { + return null; + } + } +} diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 437677ab..cd6f6040 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -28,35 +28,41 @@ import {CredentialBody, JWTInput} from './credentials'; import {IdTokenClient} from './idtokenclient'; import {GCPEnv, getEnv} from './envDetect'; import {JWT, JWTOptions} from './jwtclient'; -import { - Headers, - OAuth2Client, - OAuth2ClientOptions, - RefreshOptions, -} from './oauth2client'; +import {Headers, OAuth2ClientOptions, RefreshOptions} from './oauth2client'; import {UserRefreshClient, UserRefreshClientOptions} from './refreshclient'; +import { + ExternalAccountClient, + ExternalAccountClientOptions, +} from './externalclient'; +import { + EXTERNAL_ACCOUNT_TYPE, + BaseExternalAccountClient, +} from './baseexternalclient'; +import {AuthClient} from './authclient'; + +/** + * Defines all types of explicit clients that are determined via ADC JSON + * config file. + */ +export type JSONClient = JWT | UserRefreshClient | BaseExternalAccountClient; export interface ProjectIdCallback { (err?: Error | null, projectId?: string | null): void; } export interface CredentialCallback { - (err: Error | null, result?: UserRefreshClient | JWT): void; + (err: Error | null, result?: JSONClient): void; } // eslint-disable-next-line @typescript-eslint/no-empty-interface interface DeprecatedGetClientOptions {} export interface ADCCallback { - ( - err: Error | null, - credential?: OAuth2Client, - projectId?: string | null - ): void; + (err: Error | null, credential?: AuthClient, projectId?: string | null): void; } export interface ADCResponse { - credential: OAuth2Client; + credential: AuthClient; projectId: string | null; } @@ -72,9 +78,10 @@ export interface GoogleAuthOptions { keyFile?: string; /** - * Object containing client_email and private_key properties + * Object containing client_email and private_key properties, or the + * external account client options. */ - credentials?: CredentialBody; + credentials?: CredentialBody | ExternalAccountClientOptions; /** * Options object passed to the constructor of the client @@ -115,9 +122,9 @@ export class GoogleAuth { private _cachedProjectId?: string | null; // To save the contents of the JSON credential file - jsonContent: JWTInput | null = null; + jsonContent: JWTInput | ExternalAccountClientOptions | null = null; - cachedCredential: JWT | UserRefreshClient | Compute | null = null; + cachedCredential: JSONClient | Compute | null = null; /** * Scopes populated by the client library by default. We differentiate between @@ -180,7 +187,8 @@ export class GoogleAuth { this.getProductionProjectId() || (await this.getFileProjectId()) || (await this.getDefaultServiceProjectId()) || - (await this.getGCEProjectId()); + (await this.getGCEProjectId()) || + (await this.getExternalAccountClientProjectId()); this._cachedProjectId = projectId; if (!projectId) { throw new Error( @@ -199,6 +207,14 @@ export class GoogleAuth { return this._getDefaultProjectIdPromise; } + /** + * @returns Any scopes (user-specified or default scopes specified by the + * client library) that need to be set on the current Auth client. + */ + private getAnyScopes(): string | string[] | undefined { + return this.scopes || this.defaultScopes; + } + /** * Obtains the default service-level credentials for the application. * @param callback Optional callback. @@ -235,12 +251,12 @@ export class GoogleAuth { // If we've already got a cached credential, just return it. if (this.cachedCredential) { return { - credential: this.cachedCredential as JWT | UserRefreshClient, + credential: this.cachedCredential as JSONClient, projectId: await this.getProjectIdAsync(), }; } - let credential: JWT | UserRefreshClient | null; + let credential: JSONClient | null; let projectId: string | null; // Check for the existence of a local environment variable pointing to the // location of the credential file. This is typically used in local @@ -252,6 +268,8 @@ export class GoogleAuth { if (credential instanceof JWT) { credential.defaultScopes = this.defaultScopes; credential.scopes = this.scopes; + } else if (credential instanceof BaseExternalAccountClient) { + credential.scopes = this.getAnyScopes(); } this.cachedCredential = credential; projectId = await this.getProjectId(); @@ -266,6 +284,8 @@ export class GoogleAuth { if (credential instanceof JWT) { credential.defaultScopes = this.defaultScopes; credential.scopes = this.scopes; + } else if (credential instanceof BaseExternalAccountClient) { + credential.scopes = this.getAnyScopes(); } this.cachedCredential = credential; projectId = await this.getProjectId(); @@ -290,7 +310,7 @@ export class GoogleAuth { // For GCE, just return a default ComputeClient. It will take care of // the rest. - (options as ComputeOptions).scopes = this.scopes || this.defaultScopes; + (options as ComputeOptions).scopes = this.getAnyScopes(); this.cachedCredential = new Compute(options); projectId = await this.getProjectId(); return {projectId, credential: this.cachedCredential}; @@ -315,7 +335,7 @@ export class GoogleAuth { */ async _tryGetApplicationCredentialsFromEnvironmentVariable( options?: RefreshOptions - ): Promise { + ): Promise { const credentialsPath = process.env['GOOGLE_APPLICATION_CREDENTIALS'] || process.env['google_application_credentials']; @@ -340,7 +360,7 @@ export class GoogleAuth { */ async _tryGetApplicationCredentialsFromWellKnownFile( options?: RefreshOptions - ): Promise { + ): Promise { // First, figure out the location of the file, depending upon the OS type. let location = null; if (this._isWindows()) { @@ -385,7 +405,7 @@ export class GoogleAuth { async _getApplicationCredentialsFromFilePath( filePath: string, options: RefreshOptions = {} - ): Promise { + ): Promise { // Make sure the path looks like a string. if (!filePath || filePath.length === 0) { throw new Error('The file path is invalid.'); @@ -417,8 +437,8 @@ export class GoogleAuth { * @param options The JWT or UserRefresh options for the client * @returns JWT or UserRefresh Client with data */ - fromJSON(json: JWTInput, options?: RefreshOptions): JWT | UserRefreshClient { - let client: UserRefreshClient | JWT; + fromJSON(json: JWTInput, options?: RefreshOptions): JSONClient { + let client: JSONClient; if (!json) { throw new Error( 'Must pass in a JSON object containing the Google auth settings.' @@ -427,12 +447,19 @@ export class GoogleAuth { options = options || {}; if (json.type === 'authorized_user') { client = new UserRefreshClient(options); + client.fromJSON(json); + } else if (json.type === EXTERNAL_ACCOUNT_TYPE) { + client = ExternalAccountClient.fromJSON( + json as ExternalAccountClientOptions, + options + )!; + client.scopes = this.getAnyScopes(); } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); client.defaultScopes = this.defaultScopes; + client.fromJSON(json); } - client.fromJSON(json); return client; } @@ -446,18 +473,25 @@ export class GoogleAuth { private _cacheClientFromJSON( json: JWTInput, options?: RefreshOptions - ): JWT | UserRefreshClient { - let client: UserRefreshClient | JWT; + ): JSONClient { + let client: JSONClient; // create either a UserRefreshClient or JWT client. options = options || {}; if (json.type === 'authorized_user') { client = new UserRefreshClient(options); + client.fromJSON(json); + } else if (json.type === EXTERNAL_ACCOUNT_TYPE) { + client = ExternalAccountClient.fromJSON( + json as ExternalAccountClientOptions, + options + )!; + client.scopes = this.getAnyScopes(); } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); client.defaultScopes = this.defaultScopes; + client.fromJSON(json); } - client.fromJSON(json); // cache both raw data used to instantiate client and client itself. this.jsonContent = json; this.cachedCredential = client; @@ -469,12 +503,12 @@ export class GoogleAuth { * @param inputStream The input stream. * @param callback Optional callback. */ - fromStream(inputStream: stream.Readable): Promise; + fromStream(inputStream: stream.Readable): Promise; fromStream(inputStream: stream.Readable, callback: CredentialCallback): void; fromStream( inputStream: stream.Readable, options: RefreshOptions - ): Promise; + ): Promise; fromStream( inputStream: stream.Readable, options: RefreshOptions, @@ -484,7 +518,7 @@ export class GoogleAuth { inputStream: stream.Readable, optionsOrCallback: RefreshOptions | CredentialCallback = {}, callback?: CredentialCallback - ): Promise | void { + ): Promise | void { let options: RefreshOptions = {}; if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; @@ -504,7 +538,7 @@ export class GoogleAuth { private fromStreamAsync( inputStream: stream.Readable, options?: RefreshOptions - ): Promise { + ): Promise { return new Promise((resolve, reject) => { if (!inputStream) { throw new Error( @@ -629,6 +663,21 @@ export class GoogleAuth { } } + /** + * Gets the project ID from external account client if available. + */ + private async getExternalAccountClientProjectId(): Promise { + if (!this.jsonContent || this.jsonContent.type !== EXTERNAL_ACCOUNT_TYPE) { + return null; + } + const creds = await this.getClient(); + try { + return await (creds as BaseExternalAccountClient).getProjectId(); + } catch (e) { + return null; + } + } + /** * Gets the Compute Engine project ID if it can be inferred. */ @@ -671,8 +720,8 @@ export class GoogleAuth { if (this.jsonContent) { const credential: CredentialBody = { - client_email: this.jsonContent.client_email, - private_key: this.jsonContent.private_key, + client_email: (this.jsonContent as JWTInput).client_email, + private_key: (this.jsonContent as JWTInput).private_key, }; return credential; } diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts new file mode 100644 index 00000000..33badafa --- /dev/null +++ b/src/auth/identitypoolclient.ts @@ -0,0 +1,219 @@ +// Copyright 2021 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 {GaxiosOptions} from 'gaxios'; +import * as fs from 'fs'; +import {promisify} from 'util'; + +import { + BaseExternalAccountClient, + BaseExternalAccountClientOptions, +} from './baseexternalclient'; +import {RefreshOptions} from './oauth2client'; + +// 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'; + +interface SubjectTokenJsonResponse { + [key: string]: string; +} + +/** + * Url-sourced/file-sourced credentials json interface. + * This is used for K8s and Azure workloads. + */ +export interface IdentityPoolClientOptions + extends BaseExternalAccountClientOptions { + credential_source: { + file?: string; + url?: string; + headers?: { + [key: string]: string; + }; + format?: { + type: SubjectTokenFormatType; + subject_token_field_name?: string; + }; + }; +} + +/** + * Defines the Url-sourced and file-sourced external account clients mainly + * 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; + + /** + * Instantiate an IdentityPoolClient instance using the provided JSON + * object loaded from an external account credentials file. + * An error is thrown if the credential is not a valid file-sourced or + * url-sourced credential. + * @param options The external account options object typically loaded + * from the external account JSON credential file. + * @param additionalOptions Optional additional behavior customization + * options. These currently customize expiration threshold time and + * whether to retry on 401/403 API request errors. + */ + constructor( + options: IdentityPoolClientOptions, + additionalOptions?: RefreshOptions + ) { + super(options, additionalOptions); + this.file = options.credential_source.file; + this.url = options.credential_source.url; + this.headers = options.credential_source.headers; + if (!this.file && !this.url) { + throw new Error('No valid Identity Pool "credential_source" provided'); + } + // Text is the default format type. + this.formatType = options.credential_source.format?.type || 'text'; + this.formatSubjectTokenFieldName = + options.credential_source.format?.subject_token_field_name; + if (this.formatType !== 'json' && this.formatType !== 'text') { + throw new Error(`Invalid credential_source format "${this.formatType}"`); + } + if (this.formatType === 'json' && !this.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 + ); + } + 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) { + 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) { + throw new Error( + 'Unable to parse the subject_token from the credential_source file' + ); + } + 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. + * @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; + } +} diff --git a/src/auth/oauth2common.ts b/src/auth/oauth2common.ts new file mode 100644 index 00000000..34d1bb6d --- /dev/null +++ b/src/auth/oauth2common.ts @@ -0,0 +1,229 @@ +// Copyright 2021 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 {GaxiosOptions} from 'gaxios'; +import * as querystring from 'querystring'; + +import {Crypto, createCrypto} from '../crypto/crypto'; + +/** List of HTTP methods that accept request bodies. */ +const METHODS_SUPPORTING_REQUEST_BODY = ['PUT', 'POST', 'PATCH']; + +/** + * OAuth error codes. + * https://tools.ietf.org/html/rfc6749#section-5.2 + */ +type OAuthErrorCode = + | 'invalid_request' + | 'invalid_client' + | 'invalid_grant' + | 'unauthorized_client' + | 'unsupported_grant_type' + | 'invalid_scope' + | string; + +/** + * The standard OAuth error response. + * https://tools.ietf.org/html/rfc6749#section-5.2 + */ +export interface OAuthErrorResponse { + error: OAuthErrorCode; + error_description?: string; + error_uri?: string; +} + +/** + * OAuth client authentication types. + * https://tools.ietf.org/html/rfc6749#section-2.3 + */ +export type ConfidentialClientType = 'basic' | 'request-body'; + +/** + * Defines the client authentication credentials for basic and request-body + * credentials. + * https://tools.ietf.org/html/rfc6749#section-2.3.1 + */ +export interface ClientAuthentication { + confidentialClientType: ConfidentialClientType; + clientId: string; + clientSecret?: string; +} + +/** + * Abstract class for handling client authentication in OAuth-based + * operations. + * When request-body client authentication is used, only application/json and + * application/x-www-form-urlencoded content types for HTTP methods that support + * request bodies are supported. + */ +export abstract class OAuthClientAuthHandler { + private crypto: Crypto; + + /** + * Instantiates an OAuth client authentication handler. + * @param clientAuthentication The client auth credentials. + */ + constructor(private readonly clientAuthentication?: ClientAuthentication) { + this.crypto = createCrypto(); + } + + /** + * Applies client authentication on the OAuth request's headers or POST + * body but does not process the request. + * @param opts The GaxiosOptions whose headers or data are to be modified + * depending on the client authentication mechanism to be used. + * @param bearerToken The optional bearer token to use for authentication. + * When this is used, no client authentication credentials are needed. + */ + protected applyClientAuthenticationOptions( + opts: GaxiosOptions, + bearerToken?: string + ) { + // Inject authenticated header. + this.injectAuthenticatedHeaders(opts, bearerToken); + // Inject authenticated request body. + if (!bearerToken) { + this.injectAuthenticatedRequestBody(opts); + } + } + + /** + * Applies client authentication on the request's header if either + * basic authentication or bearer token authentication is selected. + * + * @param opts The GaxiosOptions whose headers or data are to be modified + * depending on the client authentication mechanism to be used. + * @param bearerToken The optional bearer token to use for authentication. + * When this is used, no client authentication credentials are needed. + */ + private injectAuthenticatedHeaders( + opts: GaxiosOptions, + bearerToken?: string + ) { + // Bearer token prioritized higher than basic Auth. + if (bearerToken) { + opts.headers = opts.headers || {}; + Object.assign(opts.headers, { + Authorization: `Bearer ${bearerToken}}`, + }); + } else if (this.clientAuthentication?.confidentialClientType === 'basic') { + opts.headers = opts.headers || {}; + const clientId = this.clientAuthentication!.clientId; + const clientSecret = this.clientAuthentication!.clientSecret || ''; + const base64EncodedCreds = this.crypto.encodeBase64StringUtf8( + `${clientId}:${clientSecret}` + ); + Object.assign(opts.headers, { + Authorization: `Basic ${base64EncodedCreds}`, + }); + } + } + + /** + * Applies client authentication on the request's body if request-body + * client authentication is selected. + * + * @param opts The GaxiosOptions whose headers or data are to be modified + * depending on the client authentication mechanism to be used. + */ + private injectAuthenticatedRequestBody(opts: GaxiosOptions) { + if (this.clientAuthentication?.confidentialClientType === 'request-body') { + const method = (opts.method || 'GET').toUpperCase(); + // Inject authenticated request body. + if (METHODS_SUPPORTING_REQUEST_BODY.indexOf(method) !== -1) { + // Get content-type. + let contentType; + const headers = opts.headers || {}; + for (const key in headers) { + if (key.toLowerCase() === 'content-type' && headers[key]) { + contentType = headers[key].toLowerCase(); + break; + } + } + if (contentType === 'application/x-www-form-urlencoded') { + opts.data = opts.data || ''; + const data = querystring.parse(opts.data); + Object.assign(data, { + client_id: this.clientAuthentication!.clientId, + client_secret: this.clientAuthentication!.clientSecret || '', + }); + opts.data = querystring.stringify(data); + } else if (contentType === 'application/json') { + opts.data = opts.data || {}; + Object.assign(opts.data, { + client_id: this.clientAuthentication!.clientId, + client_secret: this.clientAuthentication!.clientSecret || '', + }); + } else { + throw new Error( + `${contentType} content-types are not supported with ` + + `${this.clientAuthentication!.confidentialClientType} ` + + 'client authentication' + ); + } + } else { + throw new Error( + `${method} HTTP method does not support ` + + `${this.clientAuthentication!.confidentialClientType} ` + + 'client authentication' + ); + } + } + } +} + +/** + * Converts an OAuth error response to a native JavaScript Error. + * @param resp The OAuth error response to convert to a native Error object. + * @param err The optional original error. If provided, the error properties + * will be copied to the new error. + * @return The converted native Error object. + */ +export function getErrorFromOAuthErrorResponse( + resp: OAuthErrorResponse, + err?: Error +): Error { + // Error response. + const errorCode = resp.error; + const errorDescription = resp.error_description; + const errorUri = resp.error_uri; + let message = `Error code ${errorCode}`; + if (typeof errorDescription !== 'undefined') { + message += `: ${errorDescription}`; + } + if (typeof errorUri !== 'undefined') { + message += ` - ${errorUri}`; + } + const newError = new Error(message); + // Copy properties from original error to newly generated error. + if (err) { + const keys = Object.keys(err); + if (err.stack) { + // Copy error.stack if available. + keys.push('stack'); + } + keys.forEach(key => { + // Do not overwrite the message field. + if (key !== 'message') { + Object.defineProperty(newError, key, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: (err! as {[index: string]: any})[key], + writable: false, + enumerable: true, + }); + } + }); + } + return newError; +} diff --git a/src/auth/stscredentials.ts b/src/auth/stscredentials.ts new file mode 100644 index 00000000..c86e76cf --- /dev/null +++ b/src/auth/stscredentials.ts @@ -0,0 +1,228 @@ +// Copyright 2021 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 {GaxiosOptions, GaxiosResponse} from 'gaxios'; +import * as querystring from 'querystring'; + +import {DefaultTransporter} from '../transporters'; +import {Headers} from './oauth2client'; +import { + ClientAuthentication, + OAuthClientAuthHandler, + OAuthErrorResponse, + getErrorFromOAuthErrorResponse, +} from './oauth2common'; + +/** + * Defines the interface needed to initialize an StsCredentials instance. + * The interface does not directly map to the spec and instead is converted + * to be compliant with the JavaScript style guide. This is because this is + * instantiated internally. + * StsCredentials implement the OAuth 2.0 token exchange based on + * https://tools.ietf.org/html/rfc8693. + * Request options are defined in + * https://tools.ietf.org/html/rfc8693#section-2.1 + */ +export interface StsCredentialsOptions { + /** + * REQUIRED. The value "urn:ietf:params:oauth:grant-type:token-exchange" + * indicates that a token exchange is being performed. + */ + grantType: string; + /** + * OPTIONAL. A URI that indicates the target service or resource where the + * client intends to use the requested security token. + */ + resource?: string; + /** + * OPTIONAL. The logical name of the target service where the client + * intends to use the requested security token. This serves a purpose + * similar to the "resource" parameter but with the client providing a + * logical name for the target service. + */ + audience?: string; + /** + * OPTIONAL. A list of space-delimited, case-sensitive strings, as defined + * in Section 3.3 of [RFC6749], that allow the client to specify the desired + * scope of the requested security token in the context of the service or + * resource where the token will be used. + */ + scope?: string[]; + /** + * OPTIONAL. An identifier, as described in Section 3 of [RFC8693], eg. + * "urn:ietf:params:oauth:token-type:access_token" for the type of the + * requested security token. + */ + requestedTokenType?: string; + /** + * REQUIRED. A security token that represents the identity of the party on + * behalf of whom the request is being made. + */ + subjectToken: string; + /** + * REQUIRED. An identifier, as described in Section 3 of [RFC8693], that + * indicates the type of the security token in the "subject_token" parameter. + */ + subjectTokenType: string; + actingParty?: { + /** + * OPTIONAL. A security token that represents the identity of the acting + * party. Typically, this will be the party that is authorized to use the + * requested security token and act on behalf of the subject. + */ + actorToken: string; + /** + * An identifier, as described in Section 3, that indicates the type of the + * security token in the "actor_token" parameter. This is REQUIRED when the + * "actor_token" parameter is present in the request but MUST NOT be + * included otherwise. + */ + actorTokenType: string; + }; +} + +/** + * Defines the standard request options as defined by the OAuth token + * exchange spec: https://tools.ietf.org/html/rfc8693#section-2.1 + */ +interface StsRequestOptions { + grant_type: string; + resource?: string; + audience?: string; + scope?: string; + requested_token_type?: string; + subject_token: string; + subject_token_type: string; + actor_token?: string; + actor_token_type?: string; + client_id?: string; + client_secret?: string; + // GCP-specific non-standard field. + options?: string; +} + +/** + * Defines the OAuth 2.0 token exchange successful response based on + * https://tools.ietf.org/html/rfc8693#section-2.2.1 + */ +export interface StsSuccessfulResponse { + access_token: string; + issued_token_type: string; + token_type: string; + expires_in: number; + refresh_token?: string; + scope: string; + res?: GaxiosResponse | null; +} + +/** + * Implements the OAuth 2.0 token exchange based on + * https://tools.ietf.org/html/rfc8693 + */ +export class StsCredentials extends OAuthClientAuthHandler { + private transporter: DefaultTransporter; + + /** + * Initializes an STS credentials instance. + * @param tokenExchangeEndpoint The token exchange endpoint. + * @param clientAuthentication The client authentication credentials if + * available. + */ + constructor( + private readonly tokenExchangeEndpoint: string, + clientAuthentication?: ClientAuthentication + ) { + super(clientAuthentication); + this.transporter = new DefaultTransporter(); + } + + /** + * Exchanges the provided token for another type of token based on the + * rfc8693 spec. + * @param stsCredentialsOptions The token exchange options used to populate + * the token exchange request. + * @param additionalHeaders Optional additional headers to pass along the + * request. + * @param options Optional additional GCP-specific non-spec defined options + * to send with the request. + * Example: `&options=${encodeUriComponent(JSON.stringified(options))}` + * @return A promise that resolves with the token exchange response containing + * the requested token and its expiration time. + */ + async exchangeToken( + stsCredentialsOptions: StsCredentialsOptions, + additionalHeaders?: Headers, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options?: {[key: string]: any} + ): Promise { + const values: StsRequestOptions = { + grant_type: stsCredentialsOptions.grantType, + resource: stsCredentialsOptions.resource, + audience: stsCredentialsOptions.audience, + scope: stsCredentialsOptions.scope?.join(' '), + requested_token_type: stsCredentialsOptions.requestedTokenType, + subject_token: stsCredentialsOptions.subjectToken, + subject_token_type: stsCredentialsOptions.subjectTokenType, + actor_token: stsCredentialsOptions.actingParty?.actorToken, + actor_token_type: stsCredentialsOptions.actingParty?.actorTokenType, + // Non-standard GCP-specific options. + options: options && JSON.stringify(options), + }; + // Remove undefined fields. + Object.keys(values).forEach(key => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof (values as {[index: string]: any})[key] === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (values as {[index: string]: any})[key]; + } + }); + + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + // Inject additional STS headers if available. + Object.assign(headers, additionalHeaders || {}); + + const opts: GaxiosOptions = { + url: this.tokenExchangeEndpoint, + method: 'POST', + headers, + data: querystring.stringify(values), + responseType: 'json', + }; + // Apply OAuth client authentication. + this.applyClientAuthenticationOptions(opts); + + try { + const response = await this.transporter.request( + opts + ); + // Successful response. + const stsSuccessfulResponse = response.data; + stsSuccessfulResponse.res = response; + return stsSuccessfulResponse; + } catch (error) { + // Translate error to OAuthError. + if (error.response) { + throw getErrorFromOAuthErrorResponse( + error.response.data as OAuthErrorResponse, + // Preserve other fields from the original error. + error + ); + } + // Request could fail before the server responds. + throw error; + } + } +} diff --git a/src/crypto/browser/crypto.ts b/src/crypto/browser/crypto.ts index 5e1665f0..feba104a 100644 --- a/src/crypto/browser/crypto.ts +++ b/src/crypto/browser/crypto.ts @@ -26,7 +26,7 @@ if (typeof process === 'undefined' && typeof TextEncoder === 'undefined') { require('fast-text-encoding'); } -import {Crypto, JwkCertificate} from '../crypto'; +import {Crypto, JwkCertificate, fromArrayBufferToHex} from '../crypto'; export class BrowserCrypto implements Crypto { constructor() { @@ -140,4 +140,63 @@ export class BrowserCrypto implements Crypto { const result = base64js.fromByteArray(uint8array); return result; } + + /** + * Computes the SHA-256 hash of the provided string. + * @param str The plain text string to hash. + * @return A promise that resolves with the SHA-256 hash of the provided + * string in hexadecimal encoding. + */ + async sha256DigestHex(str: string): Promise { + // SubtleCrypto digest() method is async, so we must make + // this method async as well. + + // To calculate SHA256 digest using SubtleCrypto, we first + // need to convert an input string to an ArrayBuffer: + // eslint-disable-next-line node/no-unsupported-features/node-builtins + const inputBuffer = new TextEncoder().encode(str); + + // Result is ArrayBuffer as well. + const outputBuffer = await window.crypto.subtle.digest( + 'SHA-256', + inputBuffer + ); + + return fromArrayBufferToHex(outputBuffer); + } + + /** + * Computes the HMAC hash of a message using the provided crypto key and the + * SHA-256 algorithm. + * @param key The secret crypto key in utf-8 or ArrayBuffer format. + * @param msg The plain text message. + * @return A promise that resolves with the HMAC-SHA256 hash in ArrayBuffer + * format. + */ + async signWithHmacSha256( + key: string | ArrayBuffer, + msg: string + ): Promise { + // Convert key, if provided in ArrayBuffer format, to string. + const rawKey = + typeof key === 'string' + ? key + : String.fromCharCode(...new Uint16Array(key)); + + // eslint-disable-next-line node/no-unsupported-features/node-builtins + const enc = new TextEncoder(); + const cryptoKey = await window.crypto.subtle.importKey( + 'raw', + enc.encode(rawKey), + { + name: 'HMAC', + hash: { + name: 'SHA-256', + }, + }, + false, + ['sign'] + ); + return window.crypto.subtle.sign('HMAC', cryptoKey, enc.encode(msg)); + } } diff --git a/src/crypto/crypto.ts b/src/crypto/crypto.ts index 27ce5a9d..be50295e 100644 --- a/src/crypto/crypto.ts +++ b/src/crypto/crypto.ts @@ -51,6 +51,26 @@ export interface Crypto { ): Promise; decodeBase64StringUtf8(base64: string): string; encodeBase64StringUtf8(text: string): string; + /** + * Computes the SHA-256 hash of the provided string. + * @param str The plain text string to hash. + * @return A promise that resolves with the SHA-256 hash of the provided + * string in hexadecimal encoding. + */ + sha256DigestHex(str: string): Promise; + + /** + * Computes the HMAC hash of a message using the provided crypto key and the + * SHA-256 algorithm. + * @param key The secret crypto key in utf-8 or ArrayBuffer format. + * @param msg The plain text message. + * @return A promise that resolves with the HMAC-SHA256 hash in ArrayBuffer + * format. + */ + signWithHmacSha256( + key: string | ArrayBuffer, + msg: string + ): Promise; } export function createCrypto(): Crypto { @@ -67,3 +87,19 @@ export function hasBrowserCrypto() { typeof window.crypto.subtle !== 'undefined' ); } + +/** + * Converts an ArrayBuffer to a hexadecimal string. + * @param arrayBuffer The ArrayBuffer to convert to hexadecimal string. + * @return The hexadecimal encoding of the ArrayBuffer. + */ +export function fromArrayBufferToHex(arrayBuffer: ArrayBuffer): string { + // Convert buffer to byte array. + const byteArray = Array.from(new Uint8Array(arrayBuffer)); + // Convert bytes to hex string. + return byteArray + .map(byte => { + return byte.toString(16).padStart(2, '0'); + }) + .join(''); +} diff --git a/src/crypto/node/crypto.ts b/src/crypto/node/crypto.ts index 58b60671..eaaff400 100644 --- a/src/crypto/node/crypto.ts +++ b/src/crypto/node/crypto.ts @@ -49,4 +49,54 @@ export class NodeCrypto implements Crypto { encodeBase64StringUtf8(text: string): string { return Buffer.from(text, 'utf-8').toString('base64'); } + + /** + * Computes the SHA-256 hash of the provided string. + * @param str The plain text string to hash. + * @return A promise that resolves with the SHA-256 hash of the provided + * string in hexadecimal encoding. + */ + async sha256DigestHex(str: string): Promise { + return crypto.createHash('sha256').update(str).digest('hex'); + } + + /** + * Computes the HMAC hash of a message using the provided crypto key and the + * SHA-256 algorithm. + * @param key The secret crypto key in utf-8 or ArrayBuffer format. + * @param msg The plain text message. + * @return A promise that resolves with the HMAC-SHA256 hash in ArrayBuffer + * format. + */ + async signWithHmacSha256( + key: string | ArrayBuffer, + msg: string + ): Promise { + const cryptoKey = typeof key === 'string' ? key : toBuffer(key); + return toArrayBuffer( + crypto.createHmac('sha256', cryptoKey).update(msg).digest() + ); + } +} + +/** + * Converts a Node.js Buffer to an ArrayBuffer. + * https://stackoverflow.com/questions/8609289/convert-a-binary-nodejs-buffer-to-javascript-arraybuffer + * @param buffer The Buffer input to covert. + * @return The ArrayBuffer representation of the input. + */ +function toArrayBuffer(buffer: Buffer): ArrayBuffer { + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ); +} + +/** + * Converts an ArrayBuffer to a Node.js Buffer. + * @param arrayBuffer The ArrayBuffer input to covert. + * @return The Buffer representation of the input. + */ +function toBuffer(arrayBuffer: ArrayBuffer): Buffer { + return Buffer.from(arrayBuffer); } diff --git a/src/index.ts b/src/index.ts index 722e14a4..6e2f23b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,15 @@ export { UserRefreshClient, UserRefreshClientOptions, } from './auth/refreshclient'; +export {AwsClient, AwsClientOptions} from './auth/awsclient'; +export { + IdentityPoolClient, + IdentityPoolClientOptions, +} from './auth/identitypoolclient'; +export { + ExternalAccountClient, + ExternalAccountClientOptions, +} from './auth/externalclient'; export {DefaultTransporter} from './transporters'; const auth = new GoogleAuth(); diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts new file mode 100644 index 00000000..391616e2 --- /dev/null +++ b/test/externalclienthelper.ts @@ -0,0 +1,141 @@ +// Copyright 2021 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 * as assert from 'assert'; +import * as nock from 'nock'; +import * as qs from 'querystring'; +import {GetAccessTokenResponse} from '../src/auth/oauth2client'; +import {OAuthErrorResponse} from '../src/auth/oauth2common'; +import {StsSuccessfulResponse} from '../src/auth/stscredentials'; +import { + IamGenerateAccessTokenResponse, + ProjectInfo, +} from '../src/auth/baseexternalclient'; + +interface CloudRequestError { + error: { + code: number; + message: string; + status: string; + }; +} + +interface NockMockStsToken { + statusCode: number; + response: StsSuccessfulResponse | OAuthErrorResponse; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: {[key: string]: any}; + additionalHeaders?: {[key: string]: string}; +} + +interface NockMockGenerateAccessToken { + statusCode: number; + token: string; + response: IamGenerateAccessTokenResponse | CloudRequestError; + scopes: string[]; +} + +const defaultProjectNumber = '123456'; +const poolId = 'POOL_ID'; +const providerId = 'PROVIDER_ID'; +const baseUrl = 'https://sts.googleapis.com'; +const path = '/v1/token'; +const saEmail = 'service-1234@service-name.iam.gserviceaccount.com'; +const saBaseUrl = 'https://iamcredentials.googleapis.com'; +const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`; + +export function mockStsTokenExchange( + nockParams: NockMockStsToken[] +): nock.Scope { + const scope = nock(baseUrl); + nockParams.forEach(nockMockStsToken => { + const headers = Object.assign( + { + 'content-type': 'application/x-www-form-urlencoded', + }, + nockMockStsToken.additionalHeaders || {} + ); + scope + .post(path, qs.stringify(nockMockStsToken.request), { + reqheaders: headers, + }) + .reply(nockMockStsToken.statusCode, nockMockStsToken.response); + }); + return scope; +} + +export function mockGenerateAccessToken( + nockParams: NockMockGenerateAccessToken[] +): nock.Scope { + const scope = nock(saBaseUrl); + nockParams.forEach(nockMockGenerateAccessToken => { + const token = nockMockGenerateAccessToken.token; + scope + .post( + saPath, + { + scope: nockMockGenerateAccessToken.scopes, + }, + { + reqheaders: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } + ) + .reply( + nockMockGenerateAccessToken.statusCode, + nockMockGenerateAccessToken.response + ); + }); + return scope; +} + +export function getAudience( + projectNumber: string = defaultProjectNumber +): string { + return ( + `//iam.googleapis.com/projects/${projectNumber}` + + `/locations/global/workloadIdentityPools/${poolId}/` + + `providers/${providerId}` + ); +} + +export function getTokenUrl(): string { + return `${baseUrl}${path}`; +} + +export function getServiceAccountImpersonationUrl(): string { + return `${saBaseUrl}${saPath}`; +} + +export function assertGaxiosResponsePresent(resp: GetAccessTokenResponse) { + const gaxiosResponse = resp.res || {}; + assert('data' in gaxiosResponse && 'status' in gaxiosResponse); +} + +export function mockCloudResourceManager( + projectNumber: string, + accessToken: string, + statusCode: number, + response: ProjectInfo | CloudRequestError +): nock.Scope { + return nock('https://cloudresourcemanager.googleapis.com') + .get(`/v1/projects/${projectNumber}`, undefined, { + reqheaders: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .reply(statusCode, response); +} diff --git a/test/fixtures/aws-security-credentials-fake.json b/test/fixtures/aws-security-credentials-fake.json new file mode 100644 index 00000000..8bd8ca85 --- /dev/null +++ b/test/fixtures/aws-security-credentials-fake.json @@ -0,0 +1,9 @@ +{ + "Code" : "Success", + "LastUpdated" : "2020-08-11T19:33:07Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARD4OQDT6A77FR3CL", + "SecretAccessKey" : "Y8AfSaucF37G4PpvfguKZ3/l7Id4uocLXxX0+VTx", + "Token" : "IQoJb3JpZ2luX2VjEIz//////////wEaCXVzLWVhc3QtMiJGMEQCIH7MHX/Oy/OB8OlLQa9GrqU1B914+iMikqWQW7vPCKlgAiA/Lsv8Jcafn14owfxXn95FURZNKaaphj0ykpmS+Ki+CSq0AwhlEAAaDDA3NzA3MTM5MTk5NiIMx9sAeP1ovlMTMKLjKpEDwuJQg41/QUKx0laTZYjPlQvjwSqS3OB9P1KAXPWSLkliVMMqaHqelvMF/WO/glv3KwuTfQsavRNs3v5pcSEm4SPO3l7mCs7KrQUHwGP0neZhIKxEXy+Ls//1C/Bqt53NL+LSbaGv6RPHaX82laz2qElphg95aVLdYgIFY6JWV5fzyjgnhz0DQmy62/Vi8pNcM2/VnxeCQ8CC8dRDSt52ry2v+nc77vstuI9xV5k8mPtnaPoJDRANh0bjwY5Sdwkbp+mGRUJBAQRlNgHUJusefXQgVKBCiyJY4w3Csd8Bgj9IyDV+Azuy1jQqfFZWgP68LSz5bURyIjlWDQunO82stZ0BgplKKAa/KJHBPCp8Qi6i99uy7qh76FQAqgVTsnDuU6fGpHDcsDSGoCls2HgZjZFPeOj8mmRhFk1Xqvkbjuz8V1cJk54d3gIJvQt8gD2D6yJQZecnuGWd5K2e2HohvCc8Fc9kBl1300nUJPV+k4tr/A5R/0QfEKOZL1/k5lf1g9CREnrM8LVkGxCgdYMxLQow1uTL+QU67AHRRSp5PhhGX4Rek+01vdYSnJCMaPhSEgcLqDlQkhk6MPsyT91QMXcWmyO+cAZwUPwnRamFepuP4K8k2KVXs/LIJHLELwAZ0ekyaS7CptgOqS7uaSTFG3U+vzFZLEnGvWQ7y9IPNQZ+Dffgh4p3vF4J68y9049sI6Sr5d5wbKkcbm8hdCDHZcv4lnqohquPirLiFQ3q7B17V9krMPu3mz1cg4Ekgcrn/E09NTsxAqD8NcZ7C7ECom9r+X3zkDOxaajW6hu3Az8hGlyylDaMiFfRbBJpTIlxp7jfa7CxikNgNtEKLH9iCzvuSg2vhA==", + "Expiration" : "2020-08-11T07:35:49Z" +} diff --git a/test/fixtures/external-account-cred.json b/test/fixtures/external-account-cred.json new file mode 100644 index 00000000..9e7d029b --- /dev/null +++ b/test/fixtures/external-account-cred.json @@ -0,0 +1,9 @@ +{ + "type": "external_account", + "audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "credential_source": { + "file": "./test/fixtures/external-subject-token.txt" + } +} diff --git a/test/fixtures/external-subject-token.json b/test/fixtures/external-subject-token.json new file mode 100644 index 00000000..6bba08dc --- /dev/null +++ b/test/fixtures/external-subject-token.json @@ -0,0 +1,3 @@ +{ + "access_token": "HEADER.SIMULATED_JWT_PAYLOAD.SIGNATURE" +} diff --git a/test/fixtures/external-subject-token.txt b/test/fixtures/external-subject-token.txt new file mode 100644 index 00000000..c668d8f7 --- /dev/null +++ b/test/fixtures/external-subject-token.txt @@ -0,0 +1 @@ +HEADER.SIMULATED_JWT_PAYLOAD.SIGNATURE \ No newline at end of file diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts new file mode 100644 index 00000000..b318cea2 --- /dev/null +++ b/test/test.awsclient.ts @@ -0,0 +1,712 @@ +// Copyright 2021 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 * 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 {StsSuccessfulResponse} from '../src/auth/stscredentials'; +import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import { + assertGaxiosResponsePresent, + getAudience, + getTokenUrl, + getServiceAccountImpersonationUrl, + mockGenerateAccessToken, + mockStsTokenExchange, +} from './externalclienthelper'; + +nock.disableNetConnect(); + +const ONE_HOUR_IN_SECS = 3600; + +describe('AwsClient', () => { + let clock: sinon.SinonFakeTimers; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const awsSecurityCredentials = require('../../test/fixtures/aws-security-credentials-fake.json'); + const referenceDate = new Date('2020-08-11T06:55:22.345Z'); + const amzDate = '20200811T065522Z'; + const dateStamp = '20200811'; + const awsRegion = 'us-east-2'; + const accessKeyId = awsSecurityCredentials.AccessKeyId; + const secretAccessKey = awsSecurityCredentials.SecretAccessKey; + const token = awsSecurityCredentials.Token; + const awsRole = 'gcp-aws-role'; + const audience = getAudience(); + const metadataBaseUrl = 'http://169.254.169.254'; + const awsCredentialSource = { + environment_id: 'aws1', + region_url: `${metadataBaseUrl}/latest/meta-data/placement/availability-zone`, + url: `${metadataBaseUrl}/latest/meta-data/iam/security-credentials`, + regional_cred_verification_url: + 'https://sts.{region}.amazonaws.com?' + + 'Action=GetCallerIdentity&Version=2011-06-15', + }; + const awsOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: awsCredentialSource, + }; + const awsOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + awsOptions + ); + const stsSuccessfulResponse: StsSuccessfulResponse = { + access_token: 'ACCESS_TOKEN', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: ONE_HOUR_IN_SECS, + scope: 'scope1 scope2', + }; + // Signature retrieved from "signed POST request" test in test.awsclient.ts. + const expectedSignedRequest = { + url: + 'https://sts.us-east-2.amazonaws.com' + + '?Action=GetCallerIdentity&Version=2011-06-15', + method: 'POST', + headers: { + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/` + + `${dateStamp}/${awsRegion}/sts/aws4_request, SignedHeaders=host;` + + 'x-amz-date;x-amz-security-token, Signature=' + + '73452984e4a880ffdc5c392355733ec3f5ba310d5e0609a89244440cadfe7a7a', + host: 'sts.us-east-2.amazonaws.com', + 'x-amz-date': amzDate, + 'x-amz-security-token': token, + }, + }; + const expectedSubjectToken = encodeURIComponent( + JSON.stringify({ + url: expectedSignedRequest.url, + method: expectedSignedRequest.method, + headers: [ + { + key: 'x-goog-cloud-target-resource', + value: awsOptions.audience, + }, + { + key: 'x-amz-date', + value: expectedSignedRequest.headers['x-amz-date'], + }, + { + key: 'Authorization', + value: expectedSignedRequest.headers.Authorization, + }, + { + key: 'host', + value: expectedSignedRequest.headers.host, + }, + { + key: 'x-amz-security-token', + value: expectedSignedRequest.headers['x-amz-security-token'], + }, + ], + }) + ); + // Signature retrieved from "signed request when AWS credentials have no + // token" test in test.awsclient.ts. + const expectedSignedRequestNoToken = { + url: + 'https://sts.us-east-2.amazonaws.com' + + '?Action=GetCallerIdentity&Version=2011-06-15', + method: 'POST', + headers: { + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/` + + `${dateStamp}/${awsRegion}/sts/aws4_request, SignedHeaders=host;` + + 'x-amz-date, Signature=' + + 'd095ba304919cd0d5570ba8a3787884ee78b860f268ed040ba23831d55536d56', + host: 'sts.us-east-2.amazonaws.com', + 'x-amz-date': amzDate, + }, + }; + const expectedSubjectTokenNoToken = encodeURIComponent( + JSON.stringify({ + url: expectedSignedRequestNoToken.url, + method: expectedSignedRequestNoToken.method, + headers: [ + { + key: 'x-goog-cloud-target-resource', + value: awsOptions.audience, + }, + { + key: 'x-amz-date', + value: expectedSignedRequestNoToken.headers['x-amz-date'], + }, + { + key: 'Authorization', + value: expectedSignedRequestNoToken.headers.Authorization, + }, + { + key: 'host', + value: expectedSignedRequestNoToken.headers.host, + }, + ], + }) + ); + + beforeEach(() => { + clock = sinon.useFakeTimers(referenceDate); + }); + + afterEach(() => { + if (clock) { + clock.restore(); + } + }); + + it('should be a subclass of ExternalAccountClient', () => { + assert(AwsClient.prototype instanceof BaseExternalAccountClient); + }); + + describe('Constructor', () => { + const requiredCredentialSourceFields = [ + 'environment_id', + 'regional_cred_verification_url', + ]; + requiredCredentialSourceFields.forEach(required => { + it(`should throw when credential_source is missing ${required}`, () => { + const expectedError = new Error( + 'No valid AWS "credential_source" provided' + ); + const invalidCredentialSource = Object.assign({}, awsCredentialSource); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (invalidCredentialSource as any)[required]; + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: invalidCredentialSource, + }; + + assert.throws(() => { + return new AwsClient(invalidOptions); + }, expectedError); + }); + }); + + it('should throw when an unsupported environment ID is provided', () => { + const expectedError = new Error( + 'No valid AWS "credential_source" provided' + ); + const invalidCredentialSource = Object.assign({}, awsCredentialSource); + invalidCredentialSource.environment_id = 'azure1'; + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: invalidCredentialSource, + }; + + assert.throws(() => { + return new AwsClient(invalidOptions); + }, expectedError); + }); + + it('should throw when an unsupported environment version is provided', () => { + const expectedError = new Error( + 'aws version "3" is not supported in the current build.' + ); + const invalidCredentialSource = Object.assign({}, awsCredentialSource); + invalidCredentialSource.environment_id = 'aws3'; + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: invalidCredentialSource, + }; + + assert.throws(() => { + return new AwsClient(invalidOptions); + }, expectedError); + }); + + it('should not throw when valid AWS options are provided', () => { + assert.doesNotThrow(() => { + return new AwsClient(awsOptions); + }); + }); + }); + + describe('for security_credentials retrieved tokens', () => { + describe('retrieveSubjectToken()', () => { + it('should resolve on success', async () => { + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, awsSecurityCredentials); + + const client = new AwsClient(awsOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + scope.done(); + }); + + it('should resolve on success with permanent creds', async () => { + const permanentAwsSecurityCredentials = Object.assign( + {}, + awsSecurityCredentials + ); + delete permanentAwsSecurityCredentials.Token; + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, permanentAwsSecurityCredentials); + + const client = new AwsClient(awsOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + scope.done(); + }); + + it('should re-calculate role name on successive calls', async () => { + const otherRole = 'some-other-role'; + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, awsSecurityCredentials) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, otherRole) + .get(`/latest/meta-data/iam/security-credentials/${otherRole}`) + .reply(200, awsSecurityCredentials); + + const client = new AwsClient(awsOptions); + const subjectToken = await client.retrieveSubjectToken(); + const subjectToken2 = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + assert.deepEqual(subjectToken2, expectedSubjectToken); + scope.done(); + }); + + it('should reject when AWS region is not determined', async () => { + // Simulate error during region retrieval. + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(500); + + const client = new AwsClient(awsOptions); + + await assert.rejects(client.retrieveSubjectToken(), { + code: '500', + }); + scope.done(); + }); + + it('should reject when AWS role name is not determined', async () => { + // Simulate error during region retrieval. + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(403); + + const client = new AwsClient(awsOptions); + + await assert.rejects(client.retrieveSubjectToken(), { + code: '403', + }); + scope.done(); + }); + + it('should reject when AWS security creds are not found', async () => { + // Simulate error during security credentials retrieval. + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(408); + + const client = new AwsClient(awsOptions); + + await assert.rejects(client.retrieveSubjectToken(), { + code: '408', + }); + scope.done(); + }); + + it('should reject when "credential_source.url" is missing', async () => { + const expectedError = new Error( + 'Unable to determine AWS role name due to missing ' + + '"options.credential_source.url"' + ); + const missingUrlCredentialSource = Object.assign( + {}, + awsCredentialSource + ); + delete missingUrlCredentialSource.url; + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: missingUrlCredentialSource, + }; + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`); + + const client = new AwsClient(invalidOptions); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + scope.done(); + }); + + it('should reject when "credential_source.region_url" is missing', async () => { + const expectedError = new Error( + 'Unable to determine AWS region due to missing ' + + '"options.credential_source.region_url"' + ); + const missingRegionUrlCredentialSource = Object.assign( + {}, + awsCredentialSource + ); + delete missingRegionUrlCredentialSource.region_url; + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: missingRegionUrlCredentialSource, + }; + + const client = new AwsClient(invalidOptions); + + 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: expectedSubjectToken, + subject_token_type: + 'urn:ietf:params:aws:token-type:aws4_request', + }, + }, + ]) + ); + scopes.push( + nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, awsSecurityCredentials) + ); + + const client = new AwsClient(awsOptions); + 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 saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date( + referenceDate.getTime() + ONE_HOUR_IN_SECS * 1000 + ).toISOString(), + }; + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(200, awsRole) + .get(`/latest/meta-data/iam/security-credentials/${awsRole}`) + .reply(200, awsSecurityCredentials), + 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: expectedSubjectToken, + subject_token_type: + 'urn:ietf:params:aws:token-type:aws4_request', + }, + }, + ]), + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new AwsClient(awsOptionsWithSA); + 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 on retrieveSubjectToken error', async () => { + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + .get('/latest/meta-data/iam/security-credentials') + .reply(500); + + const client = new AwsClient(awsOptions); + + await assert.rejects(client.getAccessToken(), { + code: '500', + }); + scope.done(); + }); + }); + }); + + describe('for environment variables retrieved tokens', () => { + let envAwsAccessKeyId: string | undefined; + let envAwsSecretAccessKey: string | undefined; + let envAwsSessionToken: string | undefined; + let envAwsRegion: string | undefined; + + beforeEach(() => { + // Store external state. + envAwsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; + envAwsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + envAwsSessionToken = process.env.AWS_SESSION_TOKEN; + envAwsAccessKeyId = process.env.AWS_REGION; + // Reset environment variables. + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + delete process.env.AWS_REGION; + }); + + afterEach(() => { + // Restore environment variables. + if (envAwsAccessKeyId) { + process.env.AWS_ACCESS_KEY_ID = envAwsAccessKeyId; + } else { + delete process.env.AWS_ACCESS_KEY_ID; + } + if (envAwsSecretAccessKey) { + process.env.AWS_SECRET_ACCESS_KEY = envAwsSecretAccessKey; + } else { + delete process.env.AWS_SECRET_ACCESS_KEY; + } + if (envAwsSessionToken) { + process.env.AWS_SESSION_TOKEN = envAwsSessionToken; + } else { + delete process.env.AWS_SESSION_TOKEN; + } + if (envAwsRegion) { + process.env.AWS_REGION = envAwsRegion; + } else { + delete process.env.AWS_REGION; + } + }); + + describe('retrieveSubjectToken()', () => { + it('should resolve on success for permanent creds', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`); + + const client = new AwsClient(awsOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + scope.done(); + }); + + it('should resolve on success for temporary creds', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + process.env.AWS_SESSION_TOKEN = token; + + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`); + + const client = new AwsClient(awsOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectToken); + scope.done(); + }); + + it('should reject when AWS region is not determined', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + + // Simulate error during region retrieval. + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(500); + + const client = new AwsClient(awsOptions); + + await assert.rejects(client.retrieveSubjectToken(), { + code: '500', + }); + scope.done(); + }); + + it('should resolve when AWS region is set as environment variable', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + process.env.AWS_REGION = awsRegion; + + const client = new AwsClient(awsOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + }); + + it('should resolve without optional credentials_source fields', async () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + process.env.AWS_REGION = awsRegion; + const requiredOnlyCredentialSource = Object.assign( + {}, + awsCredentialSource + ); + // Remove all optional fields. + delete requiredOnlyCredentialSource.region_url; + delete requiredOnlyCredentialSource.url; + const requiredOnlyOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: requiredOnlyCredentialSource, + }; + + const client = new AwsClient(requiredOnlyOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, expectedSubjectTokenNoToken); + }); + }); + + 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', + }, + }, + ]) + ); + scopes.push( + nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(200, `${awsRegion}b`) + ); + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + + const client = new AwsClient(awsOptions); + 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 () => { + process.env.AWS_ACCESS_KEY_ID = accessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = secretAccessKey; + + const scope = nock(metadataBaseUrl) + .get('/latest/meta-data/placement/availability-zone') + .reply(500); + + const client = new AwsClient(awsOptions); + + await assert.rejects(client.getAccessToken(), { + code: '500', + }); + scope.done(); + }); + }); + }); +}); diff --git a/test/test.awsrequestsigner.ts b/test/test.awsrequestsigner.ts new file mode 100644 index 00000000..ebe3824e --- /dev/null +++ b/test/test.awsrequestsigner.ts @@ -0,0 +1,742 @@ +// Copyright 2021 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 * as assert from 'assert'; +import {describe, it, afterEach, beforeEach} from 'mocha'; +import * as sinon from 'sinon'; +import {AwsRequestSigner} from '../src/auth/awsrequestsigner'; +import {GaxiosOptions} from 'gaxios'; + +/** Defines the interface to facilitate testing of AWS request signing. */ +interface AwsRequestSignerTest { + // Test description. + description: string; + // The mock time when the signature is generated. + referenceDate: Date; + // AWS request signer instance. + instance: AwsRequestSigner; + // The raw input request. + originalRequest: GaxiosOptions; + // The expected signed output request. + getSignedRequest: () => GaxiosOptions; +} + +describe('AwsRequestSigner', () => { + let clock: sinon.SinonFakeTimers; + // Load AWS credentials from a sample security_credentials response. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const awsSecurityCredentials = require('../../test/fixtures/aws-security-credentials-fake.json'); + const accessKeyId = awsSecurityCredentials.AccessKeyId; + const secretAccessKey = awsSecurityCredentials.SecretAccessKey; + const token = awsSecurityCredentials.Token; + + beforeEach(() => { + clock = sinon.useFakeTimers(0); + }); + + afterEach(() => { + if (clock) { + clock.restore(); + } + }); + + describe('getRequestOptions()', () => { + const awsError = new Error('Error retrieving AWS security credentials'); + // Successful AWS credentials retrieval. + // In this case, temporary credentials are returned. + const getCredentials = async () => { + return { + accessKeyId, + secretAccessKey, + token, + }; + }; + // Successful AWS credentials retrieval. + // In this case, permanent credentials are returned (no session token). + const getCredentialsWithoutToken = async () => { + return { + accessKeyId, + secretAccessKey, + }; + }; + // Failing AWS credentials retrieval. + const getCredentialsUnsuccessful = async () => { + throw awsError; + }; + // Sample request parameters. + const requestParams = { + KeySchema: [ + { + KeyType: 'HASH', + AttributeName: 'Id', + }, + ], + TableName: 'TestTable', + AttributeDefinitions: [ + { + AttributeName: 'Id', + AttributeType: 'S', + }, + ], + ProvisionedThroughput: { + WriteCapacityUnits: 5, + ReadCapacityUnits: 5, + }, + }; + // List of various requests and their expected signatures. + // Examples source: + // https://docs.aws.amazon.com/general/latest/gr/sigv4-signed-request-examples.html + const getRequestOptionsTests: AwsRequestSignerTest[] = [ + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla.sreq + description: 'signed GET request (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'GET', + url: 'https://host.foo.com', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + 'b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470'; + return { + url: 'https://host.foo.com', + method: 'GET', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-relative-relative.sreq + description: + 'signed GET request with relative path (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'GET', + url: 'https://host.foo.com/foo/bar/../..', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + 'b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470'; + return { + url: 'https://host.foo.com/foo/bar/../..', + method: 'GET', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-dot-slash.sreq + description: 'signed GET request with /./ path (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'GET', + url: 'https://host.foo.com/./', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + 'b27ccfbfa7df52a200ff74193ca6e32d4b48b8856fab7ebf1c595d0670a7e470'; + return { + url: 'https://host.foo.com/./', + method: 'GET', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-slash-pointless-dot.sreq + description: + 'signed GET request with pointless dot path (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'GET', + url: 'https://host.foo.com/./foo', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + '910e4d6c9abafaf87898e1eb4c929135782ea25bb0279703146455745391e63a'; + return { + url: 'https://host.foo.com/./foo', + method: 'GET', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-utf8.sreq + description: 'signed GET request with utf8 path (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'GET', + url: 'https://host.foo.com/%E1%88%B4', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + '8d6634c189aa8c75c2e51e106b6b5121bed103fdb351f7d7d4381c738823af74'; + return { + url: 'https://host.foo.com/%E1%88%B4', + method: 'GET', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-query-order-key-case.sreq + description: + 'signed GET request with uplicate query key (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'GET', + url: 'https://host.foo.com/?foo=Zoo&foo=aha', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + 'be7148d34ebccdc6423b19085378aa0bee970bdc61d144bd1a8c48c33079ab09'; + return { + url: 'https://host.foo.com/?foo=Zoo&foo=aha', + method: 'GET', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-ut8-query.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-vanilla-ut8-query.sreq + description: 'signed GET request with utf8 query (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'GET', + url: 'https://host.foo.com/?ሴ=bar', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + '6fb359e9a05394cc7074e0feb42573a2601abc0c869a953e8c5c12e4e01f1a8c'; + return { + url: 'https://host.foo.com/?ሴ=bar', + method: 'GET', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-key-sort.sreq + description: + 'signed POST request with sorted headers (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'POST', + url: 'https://host.foo.com/', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + ZOO: 'zoobar', + }, + }, + getSignedRequest: () => { + const signature = + 'b7a95a52518abbca0964a999a880429ab734f35ebbf1235bd79a5de87756dc4a'; + return { + url: 'https://host.foo.com/', + method: 'POST', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host;zoo, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + ZOO: 'zoobar', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-header-value-case.sreq + description: + 'signed POST request with upper case header value from ' + + 'AWS Python test harness', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'POST', + url: 'https://host.foo.com/', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + zoo: 'ZOOBAR', + }, + }, + getSignedRequest: () => { + const signature = + '273313af9d0c265c531e11db70bbd653f3ba074c1009239e8559d3987039cad7'; + return { + url: 'https://host.foo.com/', + method: 'POST', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host;zoo, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + zoo: 'ZOOBAR', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.sreq + description: + 'signed POST request with header and no body (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'POST', + url: 'https://host.foo.com', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + p: 'phfft', + }, + }, + getSignedRequest: () => { + const signature = + 'debf546796015d6f6ded8626f5ce98597c33b47b9164cf6b17b4642036fcb592'; + return { + url: 'https://host.foo.com', + method: 'POST', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host;p, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + p: 'phfft', + }, + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-x-www-form-urlencoded.sreq + description: + 'signed POST request with body and no header (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'POST', + url: 'https://host.foo.com', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + body: 'foo=bar', + }, + getSignedRequest: () => { + const signature = + '5a15b22cf462f047318703b92e6f4f38884e4a7ab7b1d6426ca46a8bd1c26cbc'; + return { + url: 'https://host.foo.com', + method: 'POST', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + 'aws4_request, SignedHeaders=content-type;date;host, ' + + `Signature=${signature}`, + host: 'host.foo.com', + 'Content-Type': 'application/x-www-form-urlencoded', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + body: 'foo=bar', + }; + }, + }, + { + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.req + // https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/post-vanilla-query.sreq + description: + 'signed POST request with querystring (AWS botocore tests)', + referenceDate: new Date('2011-09-09T23:36:00.000Z'), + instance: new AwsRequestSigner(async () => { + return { + accessKeyId: 'AKIDEXAMPLE', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }; + }, 'us-east-1'), + originalRequest: { + method: 'POST', + url: 'https://host.foo.com/?foo=bar', + headers: { + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }, + getSignedRequest: () => { + const signature = + 'b6e3b79003ce0743a491606ba1035a804593b0efb1e20a11cba83f8c25a57a92'; + return { + url: 'https://host.foo.com/?foo=bar', + method: 'POST', + headers: { + Authorization: + 'AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20110909/us-east-1/host/' + + `aws4_request, SignedHeaders=date;host, Signature=${signature}`, + host: 'host.foo.com', + date: 'Mon, 09 Sep 2011 23:36:00 GMT', + }, + }; + }, + }, + { + description: 'signed GET request', + referenceDate: new Date('2020-08-11T06:55:22.345Z'), + instance: new AwsRequestSigner(getCredentials, 'us-east-2'), + originalRequest: { + url: + 'https://ec2.us-east-2.amazonaws.com?' + + 'Action=DescribeRegions&Version=2013-10-15', + }, + getSignedRequest: () => { + const amzDate = '20200811T065522Z'; + const dateStamp = '20200811'; + const signature = + '631ea80cddfaa545fdadb120dc92c9f18166e38a5c47b50fab9fce476e022855'; + return { + url: + 'https://ec2.us-east-2.amazonaws.com?' + + 'Action=DescribeRegions&Version=2013-10-15', + method: 'GET', + headers: { + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/` + + `${dateStamp}/us-east-2/ec2/aws4_request, SignedHeaders=host;` + + `x-amz-date;x-amz-security-token, Signature=${signature}`, + host: 'ec2.us-east-2.amazonaws.com', + 'x-amz-date': amzDate, + 'x-amz-security-token': token, + }, + }; + }, + }, + { + description: 'signed POST request', + referenceDate: new Date('2020-08-11T06:55:22.345Z'), + instance: new AwsRequestSigner(getCredentials, 'us-east-2'), + originalRequest: { + url: + 'https://sts.us-east-2.amazonaws.com' + + '?Action=GetCallerIdentity&Version=2011-06-15', + method: 'POST', + }, + getSignedRequest: () => { + const amzDate = '20200811T065522Z'; + const dateStamp = '20200811'; + const signature = + '73452984e4a880ffdc5c392355733ec3f5ba310d5e0609a89244440cadfe7a7a'; + return { + url: + 'https://sts.us-east-2.amazonaws.com' + + '?Action=GetCallerIdentity&Version=2011-06-15', + method: 'POST', + headers: { + 'x-amz-date': amzDate, + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/` + + `${dateStamp}/us-east-2/sts/aws4_request, SignedHeaders=host;` + + `x-amz-date;x-amz-security-token, Signature=${signature}`, + host: 'sts.us-east-2.amazonaws.com', + 'x-amz-security-token': token, + }, + }; + }, + }, + { + description: 'signed request when AWS credentials have no token', + referenceDate: new Date('2020-08-11T06:55:22.345Z'), + instance: new AwsRequestSigner(getCredentialsWithoutToken, 'us-east-2'), + originalRequest: { + url: + 'https://sts.us-east-2.amazonaws.com' + + '?Action=GetCallerIdentity&Version=2011-06-15', + method: 'POST', + }, + getSignedRequest: () => { + const amzDate = '20200811T065522Z'; + const dateStamp = '20200811'; + const signature = + 'd095ba304919cd0d5570ba8a3787884ee78b860f268ed040ba23831d55536d56'; + return { + url: + 'https://sts.us-east-2.amazonaws.com' + + '?Action=GetCallerIdentity&Version=2011-06-15', + method: 'POST', + headers: { + 'x-amz-date': amzDate, + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/` + + `${dateStamp}/us-east-2/sts/aws4_request, SignedHeaders=host;` + + `x-amz-date, Signature=${signature}`, + host: 'sts.us-east-2.amazonaws.com', + }, + }; + }, + }, + { + description: 'signed POST request with additional headers/body', + referenceDate: new Date('2020-08-11T06:55:22.345Z'), + instance: new AwsRequestSigner(getCredentials, 'us-east-2'), + originalRequest: { + url: 'https://dynamodb.us-east-2.amazonaws.com/', + method: 'POST', + headers: { + 'Content-Type': 'application/x-amz-json-1.0', + 'x-amz-target': 'DynamoDB_20120810.CreateTable', + }, + body: JSON.stringify(requestParams), + }, + getSignedRequest: () => { + const amzDate = '20200811T065522Z'; + const dateStamp = '20200811'; + const signature = + 'fdaa5b9cc9c86b80fe61eaf504141c0b3523780349120f2bd8145448456e0385'; + return { + url: 'https://dynamodb.us-east-2.amazonaws.com/', + method: 'POST', + headers: { + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/` + + `${dateStamp}/us-east-2/dynamodb/aws4_request, SignedHeaders=` + + 'content-type;host;x-amz-date;x-amz-security-token;x-amz-target' + + `, Signature=${signature}`, + 'Content-Type': 'application/x-amz-json-1.0', + host: 'dynamodb.us-east-2.amazonaws.com', + 'x-amz-date': amzDate, + 'x-amz-security-token': token, + 'x-amz-target': 'DynamoDB_20120810.CreateTable', + }, + body: JSON.stringify(requestParams), + }; + }, + }, + { + description: 'signed POST request with additional headers/data', + referenceDate: new Date('2020-08-11T06:55:22.345Z'), + instance: new AwsRequestSigner(getCredentials, 'us-east-2'), + originalRequest: { + url: 'https://dynamodb.us-east-2.amazonaws.com/', + method: 'POST', + headers: { + 'Content-Type': 'application/x-amz-json-1.0', + 'x-amz-target': 'DynamoDB_20120810.CreateTable', + }, + data: requestParams, + }, + getSignedRequest: () => { + const amzDate = '20200811T065522Z'; + const dateStamp = '20200811'; + const signature = + 'fdaa5b9cc9c86b80fe61eaf504141c0b3523780349120f2bd8145448456e0385'; + return { + url: 'https://dynamodb.us-east-2.amazonaws.com/', + method: 'POST', + headers: { + Authorization: + `AWS4-HMAC-SHA256 Credential=${accessKeyId}/` + + `${dateStamp}/us-east-2/dynamodb/aws4_request, SignedHeaders=` + + 'content-type;host;x-amz-date;x-amz-security-token;x-amz-target' + + `, Signature=${signature}`, + 'Content-Type': 'application/x-amz-json-1.0', + host: 'dynamodb.us-east-2.amazonaws.com', + 'x-amz-date': amzDate, + 'x-amz-security-token': token, + 'x-amz-target': 'DynamoDB_20120810.CreateTable', + }, + body: JSON.stringify(requestParams), + }; + }, + }, + ]; + + getRequestOptionsTests.forEach(test => { + it(`should resolve with the expected ${test.description}`, async () => { + clock.tick(test.referenceDate.getTime()); + const actualSignedRequest = await test.instance.getRequestOptions( + test.originalRequest + ); + assert.deepStrictEqual(actualSignedRequest, test.getSignedRequest()); + }); + }); + + it('should reject with underlying getCredentials error', async () => { + const awsRequestSigner = new AwsRequestSigner( + getCredentialsUnsuccessful, + 'us-east-2' + ); + const options: GaxiosOptions = { + url: + 'https://sts.us-east-2.amazonaws.com' + + '?Action=GetCallerIdentity&Version=2011-06-15', + method: 'POST', + }; + + await assert.rejects( + awsRequestSigner.getRequestOptions(options), + awsError + ); + }); + + it('should reject when no URL is available', async () => { + const invalidOptionsError = new Error( + '"url" is required in "amzOptions"' + ); + const awsRequestSigner = new AwsRequestSigner( + getCredentials, + 'us-east-2' + ); + + await assert.rejects( + awsRequestSigner.getRequestOptions({}), + invalidOptionsError + ); + }); + }); +}); diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts new file mode 100644 index 00000000..a5ede057 --- /dev/null +++ b/test/test.baseexternalclient.ts @@ -0,0 +1,1847 @@ +// Copyright 2021 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 * as assert from 'assert'; +import {describe, it, afterEach} from 'mocha'; +import * as nock from 'nock'; +import * as sinon from 'sinon'; +import {createCrypto} from '../src/crypto/crypto'; +import {Credentials} from '../src/auth/credentials'; +import {StsSuccessfulResponse} from '../src/auth/stscredentials'; +import { + EXPIRATION_TIME_OFFSET, + BaseExternalAccountClient, +} from '../src/auth/baseexternalclient'; +import { + OAuthErrorResponse, + getErrorFromOAuthErrorResponse, +} from '../src/auth/oauth2common'; +import {GaxiosError} from 'gaxios'; +import { + assertGaxiosResponsePresent, + getAudience, + getTokenUrl, + getServiceAccountImpersonationUrl, + mockCloudResourceManager, + mockGenerateAccessToken, + mockStsTokenExchange, +} from './externalclienthelper'; + +nock.disableNetConnect(); + +interface SampleResponse { + foo: string; + bar: number; +} + +/** Test class to test abstract class ExternalAccountClient. */ +class TestExternalAccountClient extends BaseExternalAccountClient { + private counter = 0; + + async retrieveSubjectToken(): Promise { + // Increment subject_token counter each time this is called. + return `subject_token_${this.counter++}`; + } +} + +const ONE_HOUR_IN_SECS = 3600; + +describe('BaseExternalAccountClient', () => { + let clock: sinon.SinonFakeTimers; + const crypto = createCrypto(); + const audience = getAudience(); + const externalAccountOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: '/var/run/secrets/goog.id/token', + }, + }; + const externalAccountOptionsWithCreds = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: '/var/run/secrets/goog.id/token', + }, + client_id: 'CLIENT_ID', + client_secret: 'SECRET', + }; + const basicAuthCreds = + `${externalAccountOptionsWithCreds.client_id}:` + + `${externalAccountOptionsWithCreds.client_secret}`; + const stsSuccessfulResponse: StsSuccessfulResponse = { + access_token: 'ACCESS_TOKEN', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: ONE_HOUR_IN_SECS, + scope: 'scope1 scope2', + }; + const externalAccountOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + externalAccountOptions + ); + const externalAccountOptionsWithCredsAndSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + externalAccountOptionsWithCreds + ); + const indeterminableProjectIdAudiences = [ + // Legacy K8s audience format. + 'identitynamespace:1f12345:my_provider', + // Unrealistic audiences. + '//iam.googleapis.com/projects', + '//iam.googleapis.com/projects/', + '//iam.googleapis.com/project/123456', + '//iam.googleapis.com/projects//123456', + '//iam.googleapis.com/prefix_projects/123456', + '//iam.googleapis.com/projects_suffix/123456', + ]; + + afterEach(() => { + nock.cleanAll(); + if (clock) { + clock.restore(); + } + }); + + describe('Constructor', () => { + it('should throw on invalid type', () => { + const expectedError = new Error( + 'Expected "external_account" type but received "invalid"' + ); + const invalidOptions = Object.assign({}, externalAccountOptions); + invalidOptions.type = 'invalid'; + + assert.throws(() => { + return new TestExternalAccountClient(invalidOptions); + }, expectedError); + }); + + it('should not throw on valid options', () => { + assert.doesNotThrow(() => { + return new TestExternalAccountClient(externalAccountOptions); + }); + }); + + it('should set default RefreshOptions', () => { + const client = new TestExternalAccountClient(externalAccountOptions); + + assert(!client.forceRefreshOnFailure); + assert(client.eagerRefreshThresholdMillis === EXPIRATION_TIME_OFFSET); + }); + + it('should set custom RefreshOptions', () => { + const refreshOptions = { + eagerRefreshThresholdMillis: 5000, + forceRefreshOnFailure: true, + }; + const client = new TestExternalAccountClient( + externalAccountOptions, + refreshOptions + ); + + assert.strictEqual( + client.forceRefreshOnFailure, + refreshOptions.forceRefreshOnFailure + ); + assert.strictEqual( + client.eagerRefreshThresholdMillis, + refreshOptions.eagerRefreshThresholdMillis + ); + }); + }); + + describe('projectNumber', () => { + it('should be set if determinable', () => { + const projectNumber = 'my-proj-number'; + const options = Object.assign({}, externalAccountOptions); + options.audience = getAudience(projectNumber); + const client = new TestExternalAccountClient(options); + + assert.equal(client.projectNumber, projectNumber); + }); + + indeterminableProjectIdAudiences.forEach(audience => { + it(`should resolve with null on audience=${audience}`, async () => { + const modifiedOptions = Object.assign({}, externalAccountOptions); + modifiedOptions.audience = audience; + const client = new TestExternalAccountClient(modifiedOptions); + + assert(client.projectNumber === null); + }); + }); + }); + + describe('getProjectId()', () => { + it('should resolve with projectId when determinable', async () => { + const projectNumber = 'my-proj-number'; + const projectId = 'my-proj-id'; + const response = { + projectNumber, + projectId, + lifecycleState: 'ACTIVE', + name: 'project-name', + createTime: '2018-11-06T04:42:54.109Z', + parent: { + type: 'folder', + id: '12345678901', + }, + }; + const options = Object.assign({}, externalAccountOptions); + options.audience = getAudience(projectNumber); + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: options.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', + }, + }, + ]), + mockCloudResourceManager( + projectNumber, + stsSuccessfulResponse.access_token, + 200, + response + ), + ]; + const client = new TestExternalAccountClient(options); + + const actualProjectId = await client.getProjectId(); + + assert.strictEqual(actualProjectId, projectId); + assert.strictEqual(client.projectId, projectId); + + // Next call should return cached result. + const cachedProjectId = await client.getProjectId(); + + assert.strictEqual(cachedProjectId, projectId); + scopes.forEach(scope => scope.done()); + }); + + it('should reject on request error', async () => { + const projectNumber = 'my-proj-number'; + const response = { + error: { + code: 403, + message: 'The caller does not have permission', + status: 'PERMISSION_DENIED', + }, + }; + const options = Object.assign({}, externalAccountOptions); + options.audience = getAudience(projectNumber); + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: options.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', + }, + }, + ]), + mockCloudResourceManager( + projectNumber, + stsSuccessfulResponse.access_token, + 403, + response + ), + ]; + const client = new TestExternalAccountClient(options); + + await assert.rejects( + client.getProjectId(), + /The caller does not have permission/ + ); + + assert.strictEqual(client.projectId, null); + scopes.forEach(scope => scope.done()); + }); + + indeterminableProjectIdAudiences.forEach(audience => { + it(`should resolve with null on audience=${audience}`, async () => { + const modifiedOptions = Object.assign({}, externalAccountOptions); + modifiedOptions.audience = audience; + const client = new TestExternalAccountClient(modifiedOptions); + + const actualProjectId = await client.getProjectId(); + assert(actualProjectId === null); + assert(client.projectId === null); + }); + }); + }); + + describe('getAccessToken()', () => { + describe('without service account impersonation', () => { + it('should resolve with the expected response', async () => { + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should handle underlying token exchange errors', async () => { + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid subject token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + const scope = mockStsTokenExchange([ + { + statusCode: 400, + response: errorResponse, + 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', + }, + }, + { + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + await assert.rejects( + client.getAccessToken(), + getErrorFromOAuthErrorResponse(errorResponse) + ); + // Next try should succeed. + const actualResponse = await client.getAccessToken(); + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should use explicit scopes array when provided', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'scope1 scope2', + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + client.scopes = ['scope1', 'scope2']; + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should use explicit scopes string when provided', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'scope1', + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + client.scopes = 'scope1'; + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should force refresh when cached credential is expired', async () => { + clock = sinon.useFakeTimers(0); + const emittedEvents: Credentials[] = []; + const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; + // Use different expiration time for second token to confirm tokens + // event calculates the credentials expiry_date correctly. + stsSuccessfulResponse2.expires_in = 1600; + 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', + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + // Listen to tokens events. On every event, push to list of + // emittedEvents. + client.on('tokens', tokens => { + emittedEvents.push(tokens); + }); + const actualResponse = await client.getAccessToken(); + + // tokens event should be triggered once with expected event. + assert.strictEqual(emittedEvents.length, 1); + assert.deepStrictEqual(emittedEvents[0], { + refresh_token: null, + expiry_date: new Date().getTime() + ONE_HOUR_IN_SECS * 1000, + access_token: stsSuccessfulResponse.access_token, + token_type: 'Bearer', + id_token: null, + }); + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + + // Try again. Cached credential should be returned. + clock.tick(ONE_HOUR_IN_SECS * 1000 - EXPIRATION_TIME_OFFSET - 1); + const actualCachedResponse = await client.getAccessToken(); + + // No new event should be triggered since the cached access token is + // returned. + assert.strictEqual(emittedEvents.length, 1); + delete actualCachedResponse.res; + assert.deepStrictEqual(actualCachedResponse, { + token: stsSuccessfulResponse.access_token, + }); + + // Simulate credential is expired. + clock.tick(1); + const actualNewCredResponse = await client.getAccessToken(); + + // tokens event should be triggered again with the expected event. + assert.strictEqual(emittedEvents.length, 2); + assert.deepStrictEqual(emittedEvents[1], { + refresh_token: null, + // Second expiration time should be used. + expiry_date: + new Date().getTime() + stsSuccessfulResponse2.expires_in * 1000, + access_token: stsSuccessfulResponse2.access_token, + token_type: 'Bearer', + id_token: null, + }); + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualNewCredResponse); + delete actualNewCredResponse.res; + assert.deepStrictEqual(actualNewCredResponse, { + token: stsSuccessfulResponse2.access_token, + }); + + scope.done(); + }); + + it('should respect provided eagerRefreshThresholdMillis', async () => { + clock = sinon.useFakeTimers(0); + const customThresh = 10 * 1000; + const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; + 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', + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions, { + // Override 5min threshold with 10 second threshold. + eagerRefreshThresholdMillis: customThresh, + }); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + + // Try again. Cached credential should be returned. + clock.tick(ONE_HOUR_IN_SECS * 1000 - customThresh - 1); + const actualCachedResponse = await client.getAccessToken(); + + delete actualCachedResponse.res; + assert.deepStrictEqual(actualCachedResponse, { + token: stsSuccessfulResponse.access_token, + }); + + // Simulate credential is expired. + // As current time is equal to expirationTime - customThresh, + // refresh should be triggered. + clock.tick(1); + const actualNewCredResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualNewCredResponse); + delete actualNewCredResponse.res; + assert.deepStrictEqual(actualNewCredResponse, { + token: stsSuccessfulResponse2.access_token, + }); + + scope.done(); + }); + + it('should apply basic auth when credentials are provided', async () => { + 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', + }, + additionalHeaders: { + Authorization: `Basic ${crypto.encodeBase64StringUtf8( + basicAuthCreds + )}`, + }, + }, + ]); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithCreds + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + }); + + describe('with service account impersonation', () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const saErrorResponse = { + error: { + code: 400, + message: 'Request contains an invalid argument', + status: 'INVALID_ARGUMENT', + }, + }; + + it('should resolve with the expected response', 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSA + ); + 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 handle underlying GenerateAccessToken errors', async () => { + const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; + 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: saErrorResponse.error.code, + response: saErrorResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse2.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSA + ); + await assert.rejects( + client.getAccessToken(), + new RegExp(saErrorResponse.error.message) + ); + // Next try should succeed. + 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 use explicit scopes array when provided', 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['scope1', 'scope2'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSA + ); + // These scopes should be used for the iamcredentials call. + // https://www.googleapis.com/auth/cloud-platform should be used for the + // STS token exchange request. + client.scopes = ['scope1', 'scope2']; + 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 force refresh when cached credential is expired', async () => { + clock = sinon.useFakeTimers(0); + const emittedEvents: Credentials[] = []; + const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; + saSuccessResponse.expireTime = new Date( + ONE_HOUR_IN_SECS * 1000 + ).toISOString(); + const saSuccessResponse2 = Object.assign({}, saSuccessResponse); + saSuccessResponse2.accessToken = 'SA_ACCESS_TOKEN2'; + const customExpirationInSecs = 1600; + saSuccessResponse2.expireTime = new Date( + (ONE_HOUR_IN_SECS + customExpirationInSecs) * 1000 + ).toISOString(); + 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + { + statusCode: 200, + response: saSuccessResponse2, + token: stsSuccessfulResponse2.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSA + ); + // Listen to tokens events. On every event, push to list of + // emittedEvents. + client.on('tokens', tokens => { + emittedEvents.push(tokens); + }); + const actualResponse = await client.getAccessToken(); + + // tokens event should be triggered once with expected event. + assert.strictEqual(emittedEvents.length, 1); + assert.deepStrictEqual(emittedEvents[0], { + refresh_token: null, + expiry_date: ONE_HOUR_IN_SECS * 1000, + access_token: saSuccessResponse.accessToken, + token_type: 'Bearer', + id_token: null, + }); + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: saSuccessResponse.accessToken, + }); + + // Try again. Cached credential should be returned. + clock.tick(ONE_HOUR_IN_SECS * 1000 - EXPIRATION_TIME_OFFSET - 1); + const actualCachedResponse = await client.getAccessToken(); + + // No new event should be triggered since the cached access token is + // returned. + assert.strictEqual(emittedEvents.length, 1); + delete actualCachedResponse.res; + assert.deepStrictEqual(actualCachedResponse, { + token: saSuccessResponse.accessToken, + }); + + // Simulate credential is expired. + clock.tick(1); + const actualNewCredResponse = await client.getAccessToken(); + + // tokens event should be triggered again with the expected event. + assert.strictEqual(emittedEvents.length, 2); + assert.deepStrictEqual(emittedEvents[1], { + refresh_token: null, + // Second expiration time should be used. + expiry_date: (ONE_HOUR_IN_SECS + customExpirationInSecs) * 1000, + access_token: saSuccessResponse2.accessToken, + token_type: 'Bearer', + id_token: null, + }); + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualNewCredResponse); + delete actualNewCredResponse.res; + assert.deepStrictEqual(actualNewCredResponse, { + token: saSuccessResponse2.accessToken, + }); + + scopes.forEach(scope => scope.done()); + }); + + it('should respect provided eagerRefreshThresholdMillis', async () => { + clock = sinon.useFakeTimers(0); + const customThresh = 10 * 1000; + const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; + saSuccessResponse.expireTime = new Date( + ONE_HOUR_IN_SECS * 1000 + ).toISOString(); + const saSuccessResponse2 = Object.assign({}, saSuccessResponse); + saSuccessResponse2.accessToken = 'SA_ACCESS_TOKEN2'; + saSuccessResponse2.expireTime = new Date( + 2 * ONE_HOUR_IN_SECS * 1000 + ).toISOString(); + + 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + { + statusCode: 200, + response: saSuccessResponse2, + token: stsSuccessfulResponse2.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSA, + { + // Override 5min threshold with 10 second threshold. + eagerRefreshThresholdMillis: customThresh, + } + ); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: saSuccessResponse.accessToken, + }); + + // Try again. Cached credential should be returned. + clock.tick(ONE_HOUR_IN_SECS * 1000 - customThresh - 1); + const actualCachedResponse = await client.getAccessToken(); + + delete actualCachedResponse.res; + assert.deepStrictEqual(actualCachedResponse, { + token: saSuccessResponse.accessToken, + }); + + // Simulate credential is expired. + // As current time is equal to expirationTime - customThresh, + // refresh should be triggered. + clock.tick(1); + const actualNewCredResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualNewCredResponse); + delete actualNewCredResponse.res; + assert.deepStrictEqual(actualNewCredResponse, { + token: saSuccessResponse2.accessToken, + }); + + scopes.forEach(scope => scope.done()); + }); + + it('should apply basic auth when credentials are provided', 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + additionalHeaders: { + Authorization: `Basic ${crypto.encodeBase64StringUtf8( + basicAuthCreds + )}`, + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithCredsAndSA + ); + 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()); + }); + }); + }); + + describe('getRequestHeaders()', () => { + it('should inject the authorization headers', async () => { + const expectedHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + const actualHeaders = await client.getRequestHeaders(); + + assert.deepStrictEqual(actualHeaders, expectedHeaders); + scope.done(); + }); + + it('should inject service account access token in headers', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const expectedHeaders = { + Authorization: `Bearer ${saSuccessResponse.accessToken}`, + }; + 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: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new TestExternalAccountClient( + externalAccountOptionsWithSA + ); + const actualHeaders = await client.getRequestHeaders(); + + assert.deepStrictEqual(actualHeaders, expectedHeaders); + scopes.forEach(scope => scope.done()); + }); + + it('should inject the authorization and metadata headers', async () => { + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const expectedHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + 'x-goog-user-project': quotaProjectId, + }; + 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', + }, + }, + ]); + + const optionsWithQuotaProjectId = Object.assign( + {quota_project_id: quotaProjectId}, + externalAccountOptions + ); + const client = new TestExternalAccountClient(optionsWithQuotaProjectId); + const actualHeaders = await client.getRequestHeaders(); + + assert.deepStrictEqual(expectedHeaders, actualHeaders); + scope.done(); + }); + + it('should reject when error occurs during token retrieval', async () => { + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid subject token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + const scope = mockStsTokenExchange([ + { + statusCode: 400, + response: errorResponse, + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + await assert.rejects( + client.getRequestHeaders(), + getErrorFromOAuthErrorResponse(errorResponse) + ); + scope.done(); + }); + }); + + describe('request()', () => { + it('should process HTTP request with authorization header', async () => { + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + 'x-goog-user-project': quotaProjectId, + }; + const optionsWithQuotaProjectId = Object.assign( + {quota_project_id: quotaProjectId}, + externalAccountOptions + ); + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + 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', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new TestExternalAccountClient(optionsWithQuotaProjectId); + const actualResponse = await client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }); + + assert.deepStrictEqual(actualResponse.data, exampleResponse); + scopes.forEach(scope => scope.done()); + }); + + it('should inject service account access token in headers', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const authHeaders = { + Authorization: `Bearer ${saSuccessResponse.accessToken}`, + 'x-goog-user-project': quotaProjectId, + }; + const optionsWithQuotaProjectId = Object.assign( + {quota_project_id: quotaProjectId}, + externalAccountOptionsWithSA + ); + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + 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', + }, + }, + ]), + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new TestExternalAccountClient(optionsWithQuotaProjectId); + const actualResponse = await client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }); + + assert.deepStrictEqual(actualResponse.data, exampleResponse); + scopes.forEach(scope => scope.done()); + }); + + it('should process headerless HTTP request', async () => { + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + 'x-goog-user-project': quotaProjectId, + }; + const optionsWithQuotaProjectId = Object.assign( + {quota_project_id: quotaProjectId}, + externalAccountOptions + ); + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const scopes = [ + 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', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new TestExternalAccountClient(optionsWithQuotaProjectId); + // Send request with no headers. + const actualResponse = await client.request({ + url: 'https://example.com/api', + method: 'POST', + data: exampleRequest, + responseType: 'json', + }); + + assert.deepStrictEqual(actualResponse.data, exampleResponse); + scopes.forEach(scope => scope.done()); + }); + + it('should reject when error occurs during token retrieval', async () => { + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid subject token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const scope = mockStsTokenExchange([ + { + statusCode: 400, + response: errorResponse, + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + await assert.rejects( + client.request({ + url: 'https://example.com/api', + method: 'POST', + data: exampleRequest, + responseType: 'json', + }), + getErrorFromOAuthErrorResponse(errorResponse) + ); + scope.done(); + }); + + it('should trigger callback on success when provided', done => { + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + 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', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new TestExternalAccountClient(externalAccountOptions); + client.request( + { + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }, + (err, result) => { + assert.strictEqual(err, null); + assert.deepStrictEqual(result?.data, exampleResponse); + scopes.forEach(scope => scope.done()); + done(); + } + ); + }); + + it('should trigger callback on error when provided', done => { + const errorMessage = 'Bad Request'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + 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', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(400, errorMessage), + ]; + + const client = new TestExternalAccountClient(externalAccountOptions); + client.request( + { + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }, + (err, result) => { + assert.strictEqual(err!.message, errorMessage); + assert.deepStrictEqual(result, (err as GaxiosError)!.response); + scopes.forEach(scope => scope.done()); + done(); + } + ); + }); + + it('should retry on 401 on forceRefreshOnFailure=true', async () => { + const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const authHeaders2 = { + Authorization: `Bearer ${stsSuccessfulResponse2.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + 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', + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(401) + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders2), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new TestExternalAccountClient(externalAccountOptions, { + forceRefreshOnFailure: true, + }); + const actualResponse = await client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }); + + assert.deepStrictEqual(actualResponse.data, exampleResponse); + scopes.forEach(scope => scope.done()); + }); + + it('should not retry on 401 on forceRefreshOnFailure=false', async () => { + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + 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', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(401), + ]; + + const client = new TestExternalAccountClient(externalAccountOptions); + await assert.rejects( + client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }), + { + code: '401', + } + ); + + scopes.forEach(scope => scope.done()); + }); + + it('should not retry more than once', async () => { + const stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'ACCESS_TOKEN2'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const authHeaders2 = { + Authorization: `Bearer ${stsSuccessfulResponse2.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + 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', + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + 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_1', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(403) + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders2), + }) + .reply(403), + ]; + + const client = new TestExternalAccountClient(externalAccountOptions, { + forceRefreshOnFailure: true, + }); + await assert.rejects( + client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }), + { + code: '403', + } + ); + + scopes.forEach(scope => scope.done()); + }); + }); + + describe('setCredentials()', () => { + it('should allow injection of GCP access tokens directly', async () => { + clock = sinon.useFakeTimers(0); + const credentials = { + access_token: 'INJECTED_ACCESS_TOKEN', + // Simulate token expires in 10mins. + expiry_date: new Date().getTime() + 10 * 60 * 1000, + }; + 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', + }, + }, + ]); + + const client = new TestExternalAccountClient(externalAccountOptions); + client.setCredentials(credentials); + + clock.tick(10 * 60 * 1000 - EXPIRATION_TIME_OFFSET - 1); + const tokenResponse = await client.getAccessToken(); + assert.deepStrictEqual(tokenResponse.token, credentials.access_token); + + // Simulate token expired. + clock.tick(1); + const refreshedTokenResponse = await client.getAccessToken(); + assert.deepStrictEqual( + refreshedTokenResponse.token, + stsSuccessfulResponse.access_token + ); + + scope.done(); + }); + + it('should not expire injected creds with no expiry_date', async () => { + clock = sinon.useFakeTimers(0); + const credentials = { + access_token: 'INJECTED_ACCESS_TOKEN', + }; + + const client = new TestExternalAccountClient(externalAccountOptions); + client.setCredentials(credentials); + + const tokenResponse = await client.getAccessToken(); + assert.deepStrictEqual(tokenResponse.token, credentials.access_token); + + clock.tick(ONE_HOUR_IN_SECS); + const unexpiredTokenResponse = await client.getAccessToken(); + assert.deepStrictEqual( + unexpiredTokenResponse.token, + credentials.access_token + ); + }); + }); +}); diff --git a/test/test.crypto.ts b/test/test.crypto.ts index f4faf670..101d94c8 100644 --- a/test/test.crypto.ts +++ b/test/test.crypto.ts @@ -1,12 +1,41 @@ +// Copyright 2020 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 * as fs from 'fs'; import {assert} from 'chai'; import {describe, it} from 'mocha'; -import {createCrypto} from '../src/crypto/crypto'; +import {createCrypto, fromArrayBufferToHex} from '../src/crypto/crypto'; import {NodeCrypto} from '../src/crypto/node/crypto'; const publicKey = fs.readFileSync('./test/fixtures/public.pem', 'utf-8'); const privateKey = fs.readFileSync('./test/fixtures/private.pem', 'utf-8'); +/** + * Converts a Node.js Buffer to an ArrayBuffer. + * https://stackoverflow.com/questions/8609289/convert-a-binary-nodejs-buffer-to-javascript-arraybuffer + * @param buffer The Buffer input to covert. + * @return The ArrayBuffer representation of the input. + */ +function toArrayBuffer(buffer: Buffer): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(buffer.length); + const arrayBufferView = new Uint8Array(arrayBuffer); + for (let i = 0; i < buffer.length; i++) { + arrayBufferView[i] = buffer[i]; + } + return arrayBuffer; +} + describe('crypto', () => { const crypto = createCrypto(); @@ -80,4 +109,54 @@ describe('crypto', () => { const hits = loadedModules.filter(x => x.includes('fast-text-encoding')); assert.strictEqual(hits.length, 0); }); + + it('should calculate SHA256 digest in hex encoding', async () => { + const input = 'I can calculate SHA256'; + const expectedHexDigest = + '73d08486d8bfd4fb4bc12dd8903604ddbde5ad95b6efa567bd723ce81a881122'; + + const calculatedHexDigest = await crypto.sha256DigestHex(input); + assert.strictEqual(calculatedHexDigest, expectedHexDigest); + }); + + describe('should compute the HMAC-SHA256 hash of a message', () => { + it('using string key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + const key = 'key'; + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const extectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, extectedHash.buffer); + }); + + it('using an ArrayBuffer key', async () => { + const message = 'The quick brown fox jumps over the lazy dog'; + const key = toArrayBuffer(Buffer.from('key')); + const expectedHexHash = + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; + const extectedHash = new Uint8Array( + (expectedHexHash.match(/.{1,2}/g) as string[]).map(byte => + parseInt(byte, 16) + ) + ); + + const calculatedHash = await crypto.signWithHmacSha256(key, message); + assert.deepStrictEqual(calculatedHash, extectedHash.buffer); + }); + }); + + it('should expose a method to convert an ArrayBuffer to hex', () => { + const arrayBuffer = new Uint8Array([4, 8, 0, 12, 16, 0]) + .buffer as ArrayBuffer; + const expectedHexEncoding = '0408000c1000'; + + const calculatedHexEncoding = fromArrayBufferToHex(arrayBuffer); + assert.strictEqual(calculatedHexEncoding, expectedHexEncoding); + }); }); diff --git a/test/test.externalclient.ts b/test/test.externalclient.ts new file mode 100644 index 00000000..4d1f95fe --- /dev/null +++ b/test/test.externalclient.ts @@ -0,0 +1,154 @@ +// Copyright 2021 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 * as assert from 'assert'; +import {describe, it} from 'mocha'; +import {AwsClient} from '../src/auth/awsclient'; +import {IdentityPoolClient} from '../src/auth/identitypoolclient'; +import {ExternalAccountClient} from '../src/auth/externalclient'; +import {getAudience, getTokenUrl} from './externalclienthelper'; + +const serviceAccountKeys = { + type: 'service_account', + project_id: 'PROJECT_ID', + private_key_id: 'PRIVATE_KEY_ID', + private_key: + '-----BEGIN PRIVATE KEY-----\n' + 'REDACTED\n-----END PRIVATE KEY-----\n', + client_email: '$PROJECT_ID@appspot.gserviceaccount.com', + client_id: 'CLIENT_ID', + auth_uri: 'https://accounts.google.com/o/oauth2/auth', + token_uri: 'https://accounts.google.com/o/oauth2/token', + auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs', + client_x509_cert_url: + 'https://www.googleapis.com/robot/v1/metadata/x509/' + + 'PROEJCT_ID%40appspot.gserviceaccount.com', +}; + +const fileSourcedOptions = { + type: 'external_account', + audience: getAudience(), + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.txt', + }, +}; + +const metadataBaseUrl = 'http://169.254.169.254'; +const awsCredentialSource = { + environment_id: 'aws1', + region_url: `${metadataBaseUrl}/latest/meta-data/placement/availability-zone`, + url: `${metadataBaseUrl}/latest/meta-data/iam/security-credentials`, + regional_cred_verification_url: + 'https://sts.{region}.amazonaws.com?' + + 'Action=GetCallerIdentity&Version=2011-06-15', +}; +const awsOptions = { + type: 'external_account', + audience: getAudience(), + subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', + token_url: getTokenUrl(), + credential_source: awsCredentialSource, +}; + +describe('ExternalAccountClient', () => { + describe('Constructor', () => { + it('should throw on initialization', () => { + assert.throws(() => { + return new ExternalAccountClient(); + }, /ExternalAccountClients should be initialized via/); + }); + }); + + describe('fromJSON()', () => { + const refreshOptions = { + eagerRefreshThresholdMillis: 1000 * 10, + forceRefreshOnFailure: true, + }; + + it('should return IdentityPoolClient on IdentityPoolClientOptions', () => { + const expectedClient = new IdentityPoolClient(fileSourcedOptions); + + assert.deepStrictEqual( + ExternalAccountClient.fromJSON(fileSourcedOptions), + expectedClient + ); + }); + + it('should return IdentityPoolClient with expected RefreshOptions', () => { + const expectedClient = new IdentityPoolClient( + fileSourcedOptions, + refreshOptions + ); + + assert.deepStrictEqual( + ExternalAccountClient.fromJSON(fileSourcedOptions, refreshOptions), + expectedClient + ); + }); + + it('should return AwsClient on AwsClientOptions', () => { + const expectedClient = new AwsClient(awsOptions); + + assert.deepStrictEqual( + ExternalAccountClient.fromJSON(awsOptions), + expectedClient + ); + }); + + it('should return AwsClient with expected RefreshOptions', () => { + const expectedClient = new AwsClient(awsOptions, refreshOptions); + + assert.deepStrictEqual( + ExternalAccountClient.fromJSON(awsOptions, refreshOptions), + expectedClient + ); + }); + + it('should return null when given non-ExternalAccountClientOptions', () => { + assert( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ExternalAccountClient.fromJSON(serviceAccountKeys as any) === null + ); + }); + + it('should throw when given invalid ExternalAccountClient', () => { + const invalidOptions = Object.assign({}, fileSourcedOptions); + delete invalidOptions.credential_source; + + assert.throws(() => { + return ExternalAccountClient.fromJSON(invalidOptions); + }); + }); + + it('should throw when given invalid IdentityPoolClient', () => { + const invalidOptions = Object.assign({}, fileSourcedOptions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (invalidOptions as any).credential_source = {}; + + assert.throws(() => { + return ExternalAccountClient.fromJSON(invalidOptions); + }); + }); + + it('should throw when given invalid AwsClientOptions', () => { + const invalidOptions = Object.assign({}, awsOptions); + invalidOptions.credential_source.environment_id = 'invalid'; + + assert.throws(() => { + return ExternalAccountClient.fromJSON(invalidOptions); + }); + }); + }); +}); diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index e1039ba3..a3c78eb6 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -30,10 +30,25 @@ import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import {GoogleAuth, JWT, UserRefreshClient, IdTokenClient} from '../src'; +import { + GoogleAuth, + JWT, + UserRefreshClient, + IdTokenClient, + ExternalAccountClient, + OAuth2Client, + ExternalAccountClientOptions, + RefreshOptions, +} from '../src'; import {CredentialBody} from '../src/auth/credentials'; import * as envDetect from '../src/auth/envDetect'; import {Compute} from '../src/auth/computeclient'; +import { + mockCloudResourceManager, + mockStsTokenExchange, +} from './externalclienthelper'; +import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import {AuthClient} from '../src/auth/authclient'; nock.disableNetConnect(); @@ -61,6 +76,8 @@ describe('googleauth', () => { const private2JSON = require('../../test/fixtures/private2.json'); // eslint-disable-next-line @typescript-eslint/no-var-requires const refreshJSON = require('../../test/fixtures/refresh.json'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const externalAccountJSON = require('../../test/fixtures/external-account-cred.json'); const privateKey = fs.readFileSync('./test/fixtures/private.pem', 'utf-8'); const wellKnownPathWindows = path.join( 'C:', @@ -167,10 +184,11 @@ describe('googleauth', () => { fs.createReadStream('./test/fixtures/private2.json'); } - function mockLinuxWellKnownFile() { + function mockLinuxWellKnownFile( + filePath = './test/fixtures/private2.json' + ) { exposeLinuxWellKnownFile = true; - createLinuxWellKnownStream = () => - fs.createReadStream('./test/fixtures/private2.json'); + createLinuxWellKnownStream = () => fs.createReadStream(filePath); } function nockIsGCE() { @@ -969,7 +987,7 @@ describe('googleauth', () => { // a JWTClient. assert.strictEqual( 'compute-placeholder', - res.credential.credentials.refresh_token + (res.credential as OAuth2Client).credentials.refresh_token ); }); @@ -1531,6 +1549,741 @@ describe('googleauth', () => { .post('/token') .reply(200, {}); } + + describe('for external_account types', () => { + let fromJsonSpy: sinon.SinonSpy< + [ExternalAccountClientOptions, RefreshOptions?], + BaseExternalAccountClient | null + >; + const stsSuccessfulResponse = { + access_token: 'ACCESS_TOKEN', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'scope1 scope2', + }; + const fileSubjectToken = fs.readFileSync( + externalAccountJSON.credential_source.file, + 'utf-8' + ); + // Project number should match the project number in externalAccountJSON. + const projectNumber = '123456'; + const projectId = 'my-proj-id'; + const projectInfoResponse = { + projectNumber, + projectId, + lifecycleState: 'ACTIVE', + name: 'project-name', + createTime: '2018-11-06T04:42:54.109Z', + parent: { + type: 'folder', + id: '12345678901', + }, + }; + const refreshOptions = { + eagerRefreshThresholdMillis: 5000, + forceRefreshOnFailure: true, + }; + const defaultScopes = ['http://examples.com/is/a/default/scope']; + const userScopes = ['http://examples.com/is/a/scope']; + + /** + * @return A copy of the external account JSON auth object for testing. + */ + function createExternalAccountJSON() { + const credentialSourceCopy = Object.assign( + {}, + externalAccountJSON.credential_source + ); + const jsonCopy = Object.assign({}, externalAccountJSON); + jsonCopy.credential_source = credentialSourceCopy; + return jsonCopy; + } + + /** + * Creates mock HTTP handlers for retrieving access tokens and + * optional ones for retrieving the project ID via cloud resource + * manager. + * @param mockProjectIdRetrieval Whether to mock project ID retrieval. + * @param expectedScopes The list of expected scopes. + * @return The list of nock.Scope corresponding to the mocked HTTP + * requests. + */ + function mockGetAccessTokenAndProjectId( + mockProjectIdRetrieval = true, + expectedScopes = ['https://www.googleapis.com/auth/cloud-platform'] + ): nock.Scope[] { + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: externalAccountJSON.audience, + scope: expectedScopes.join(' '), + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: fileSubjectToken, + subject_token_type: externalAccountJSON.subject_token_type, + }, + }, + ]), + ]; + + if (mockProjectIdRetrieval) { + scopes.push( + mockCloudResourceManager( + projectNumber, + stsSuccessfulResponse.access_token, + 200, + projectInfoResponse + ) + ); + } + + return scopes; + } + + /** + * Asserts that the provided client was initialized with the expected + * JSON object and RefreshOptions. + * @param actualClient The actual client to assert. + * @param json The expected JSON object that the client should be + * initialized with. + * @param options The expected RefreshOptions the client should be + * initialized with. + */ + function assertExternalAccountClientInitialized( + actualClient: AuthClient, + json: ExternalAccountClientOptions, + options: RefreshOptions + ) { + // Confirm expected client is initialized. + assert(fromJsonSpy.calledOnceWithExactly(json, options)); + assert(fromJsonSpy.returned(actualClient as BaseExternalAccountClient)); + } + + beforeEach(() => { + // Listen to external account initializations. + // This is useful to confirm that a GoogleAuth returned client is + // an external account initialized with the expected parameters. + fromJsonSpy = sinon.spy(ExternalAccountClient, 'fromJSON'); + }); + + afterEach(() => { + fromJsonSpy.restore(); + }); + + describe('fromJSON()', () => { + it('should create the expected BaseExternalAccountClient', () => { + const json = createExternalAccountJSON(); + const result = auth.fromJSON(json); + + assertExternalAccountClientInitialized(result, json, {}); + }); + + it('should honor defaultScopes when no user scopes are available', () => { + const json = createExternalAccountJSON(); + auth.defaultScopes = defaultScopes; + const result = auth.fromJSON(json); + + assertExternalAccountClientInitialized(result, json, {}); + assert.strictEqual( + (result as BaseExternalAccountClient).scopes, + defaultScopes + ); + }); + + it('should prefer user scopes over defaultScopes', () => { + const json = createExternalAccountJSON(); + const auth = new GoogleAuth({scopes: userScopes}); + auth.defaultScopes = defaultScopes; + const result = auth.fromJSON(json); + + assertExternalAccountClientInitialized(result, json, {}); + assert.strictEqual( + (result as BaseExternalAccountClient).scopes, + userScopes + ); + }); + + it('should create client with custom RefreshOptions', () => { + const json = createExternalAccountJSON(); + const result = auth.fromJSON(json, refreshOptions); + + assertExternalAccountClientInitialized(result, json, refreshOptions); + }); + + it('should throw on invalid json', () => { + const invalidJson = createExternalAccountJSON(); + delete invalidJson.credential_source; + const auth = new GoogleAuth(); + + assert.throws(() => { + auth.fromJSON(invalidJson); + }); + }); + }); + + describe('fromStream()', () => { + it('should read the stream and create a client', async () => { + const stream = fs.createReadStream( + './test/fixtures/external-account-cred.json' + ); + const actualClient = await auth.fromStream(stream); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should include provided RefreshOptions in client', async () => { + const stream = fs.createReadStream( + './test/fixtures/external-account-cred.json' + ); + const auth = new GoogleAuth(); + const result = await auth.fromStream(stream, refreshOptions); + + assertExternalAccountClientInitialized( + result, + createExternalAccountJSON(), + refreshOptions + ); + }); + }); + + describe('getApplicationDefault()', () => { + it('should use environment variable when it is set', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + // Environment variable is set up to point to + // external-account-cred.json + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + // Project ID should also be set. + assert.deepEqual(client.projectId, projectId); + scopes.forEach(s => s.done()); + }); + + it('should use defaultScopes for environment variable ADC', async () => { + const scopes = mockGetAccessTokenAndProjectId(true, defaultScopes); + // Environment variable is set up to point to + // external-account-cred.json + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + + const auth = new GoogleAuth(); + auth.defaultScopes = defaultScopes; + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + assert.strictEqual( + (client as BaseExternalAccountClient).scopes, + defaultScopes + ); + scopes.forEach(s => s.done()); + }); + + it('should prefer user scopes over defaultScopes for environment variable ADC', async () => { + const scopes = mockGetAccessTokenAndProjectId(true, userScopes); + // Environment variable is set up to point to + // external-account-cred.json + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + + const auth = new GoogleAuth({scopes: userScopes}); + auth.defaultScopes = defaultScopes; + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + assert.strictEqual( + (client as BaseExternalAccountClient).scopes, + userScopes + ); + scopes.forEach(s => s.done()); + }); + + it('should use well-known file when it is available and env const is not set', async () => { + // Set up the creds. + // * Environment variable is not set. + // * Well-known file is set up to point to external-account-cred.json + mockLinuxWellKnownFile('./test/fixtures/external-account-cred.json'); + const scopes = mockGetAccessTokenAndProjectId(); + + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + assert.deepEqual(client.projectId, projectId); + scopes.forEach(s => s.done()); + }); + + it('should use defaultScopes for well-known file ADC', async () => { + // Set up the creds. + // * Environment variable is not set. + // * Well-known file is set up to point to external-account-cred.json + mockLinuxWellKnownFile('./test/fixtures/external-account-cred.json'); + const scopes = mockGetAccessTokenAndProjectId(true, defaultScopes); + + const auth = new GoogleAuth(); + auth.defaultScopes = defaultScopes; + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + assert.strictEqual( + (client as BaseExternalAccountClient).scopes, + defaultScopes + ); + scopes.forEach(s => s.done()); + }); + + it('should prefer user scopes over defaultScopes for well-known file ADC', async () => { + // Set up the creds. + // * Environment variable is not set. + // * Well-known file is set up to point to external-account-cred.json + mockLinuxWellKnownFile('./test/fixtures/external-account-cred.json'); + const scopes = mockGetAccessTokenAndProjectId(true, userScopes); + + const auth = new GoogleAuth({scopes: userScopes}); + auth.defaultScopes = defaultScopes; + const res = await auth.getApplicationDefault(); + const client = res.credential; + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + assert.strictEqual( + (client as BaseExternalAccountClient).scopes, + userScopes + ); + scopes.forEach(s => s.done()); + }); + }); + + describe('getApplicationCredentialsFromFilePath()', () => { + it('should correctly read the file and create a valid client', async () => { + const actualClient = await auth._getApplicationCredentialsFromFilePath( + './test/fixtures/external-account-cred.json' + ); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should include provided RefreshOptions in client', async () => { + const result = await auth._getApplicationCredentialsFromFilePath( + './test/fixtures/external-account-cred.json', + refreshOptions + ); + + assertExternalAccountClientInitialized( + result, + createExternalAccountJSON(), + refreshOptions + ); + }); + }); + + describe('getProjectId()', () => { + it('should get projectId from cloud resource manager', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const actualProjectId = await auth.getProjectId(); + + assert.deepEqual(actualProjectId, projectId); + scopes.forEach(s => s.done()); + }); + + it('should prioritize explicitly provided projectId', async () => { + const explicitProjectId = 'my-explictly-specified-project-id'; + const auth = new GoogleAuth({ + credentials: createExternalAccountJSON(), + projectId: explicitProjectId, + }); + const actualProjectId = await auth.getProjectId(); + + assert.deepEqual(actualProjectId, explicitProjectId); + }); + + it('should reject when client.getProjectId() fails', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + scopes.push( + mockCloudResourceManager( + projectNumber, + stsSuccessfulResponse.access_token, + 403, + { + error: { + code: 403, + message: 'The caller does not have permission', + status: 'PERMISSION_DENIED', + }, + } + ) + ); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + + await assert.rejects( + auth.getProjectId(), + /Unable to detect a Project Id in the current environment/ + ); + scopes.forEach(s => s.done()); + }); + + it('should reject on invalid external_account client', async () => { + const invalidOptions = createExternalAccountJSON(); + invalidOptions.credential_source.file = 'invalid'; + const auth = new GoogleAuth({credentials: invalidOptions}); + + await assert.rejects( + auth.getProjectId(), + /Unable to detect a Project Id in the current environment/ + ); + }); + + it('should reject when projectId not determinable', async () => { + const json = createExternalAccountJSON(); + json.audience = 'identitynamespace:1f12345:my_provider'; + const auth = new GoogleAuth({credentials: json}); + + await assert.rejects( + auth.getProjectId(), + /Unable to detect a Project Id in the current environment/ + ); + }); + }); + + it('tryGetApplicationCredentialsFromEnvironmentVariable() should resolve', async () => { + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + const result = await auth._tryGetApplicationCredentialsFromEnvironmentVariable( + refreshOptions + ); + + assert(result); + assertExternalAccountClientInitialized( + result as AuthClient, + createExternalAccountJSON(), + refreshOptions + ); + }); + + it('tryGetApplicationCredentialsFromWellKnownFile() should resolve', async () => { + // Set up a mock to return path to a valid credentials file. + mockLinuxWellKnownFile('./test/fixtures/external-account-cred.json'); + const result = await auth._tryGetApplicationCredentialsFromWellKnownFile( + refreshOptions + ); + + assert(result); + assertExternalAccountClientInitialized( + result as AuthClient, + createExternalAccountJSON(), + refreshOptions + ); + }); + + it('getApplicationCredentialsFromFilePath() should resolve', async () => { + const result = await auth._getApplicationCredentialsFromFilePath( + './test/fixtures/external-account-cred.json', + refreshOptions + ); + + assertExternalAccountClientInitialized( + result, + createExternalAccountJSON(), + refreshOptions + ); + }); + + describe('getClient()', () => { + it('should initialize from credentials', async () => { + const auth = new GoogleAuth({ + credentials: createExternalAccountJSON(), + }); + const actualClient = await auth.getClient(); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should initialize from keyFileName', async () => { + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const actualClient = await auth.getClient(); + + assertExternalAccountClientInitialized( + actualClient, + createExternalAccountJSON(), + {} + ); + }); + + it('should initialize from ADC', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-cred.json' + ); + const auth = new GoogleAuth(); + const client = await auth.getClient(); + + assertExternalAccountClientInitialized( + client, + createExternalAccountJSON(), + {} + ); + scopes.forEach(s => s.done()); + }); + + it('should allow use defaultScopes when no scopes are available', async () => { + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + // Set defaultScopes on Auth instance. This should be set on the + // underlying client. + auth.defaultScopes = defaultScopes; + const client = (await auth.getClient()) as BaseExternalAccountClient; + + assert.strictEqual(client.scopes, defaultScopes); + }); + + it('should prefer user scopes over defaultScopes', async () => { + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({scopes: userScopes, keyFilename}); + // Set defaultScopes on Auth instance. User scopes should be used. + auth.defaultScopes = defaultScopes; + const client = (await auth.getClient()) as BaseExternalAccountClient; + + assert.strictEqual(client.scopes, userScopes); + }); + + it('should allow passing scopes to get a client', async () => { + const scopes = ['http://examples.com/is/a/scope']; + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({scopes, keyFilename}); + const client = (await auth.getClient()) as BaseExternalAccountClient; + + assert.strictEqual(client.scopes, scopes); + }); + + it('should allow passing a scope to get a client', async () => { + const scopes = 'http://examples.com/is/a/scope'; + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({scopes, keyFilename}); + const client = (await auth.getClient()) as BaseExternalAccountClient; + + assert.strictEqual(client.scopes, scopes); + }); + }); + + it('getIdTokenClient() should reject', async () => { + const auth = new GoogleAuth({credentials: createExternalAccountJSON()}); + + await assert.rejects( + auth.getIdTokenClient('a-target-audience'), + /Cannot fetch ID token in this environment/ + ); + }); + + it('sign() should reject', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + const auth = new GoogleAuth({credentials: createExternalAccountJSON()}); + + await assert.rejects( + auth.sign('abc123'), + /Cannot sign data without `client_email`/ + ); + scopes.forEach(s => s.done()); + }); + + it('getAccessToken() should get an access token', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const token = await auth.getAccessToken(); + + assert.strictEqual(token, stsSuccessfulResponse.access_token); + scopes.forEach(s => s.done()); + }); + + it('getRequestHeaders() should inject authorization header', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const headers = await auth.getRequestHeaders(); + + assert.deepStrictEqual(headers, { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }); + scopes.forEach(s => s.done()); + }); + + it('authorizeRequest() should authorize the request', async () => { + const scopes = mockGetAccessTokenAndProjectId(false); + const keyFilename = './test/fixtures/external-account-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const opts = await auth.authorizeRequest({url: 'http://example.com'}); + + assert.deepStrictEqual(opts.headers, { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }); + scopes.forEach(s => s.done()); + }); + + it('request() should make the request with auth header', async () => { + const url = 'http://example.com'; + const data = {breakfast: 'coffee'}; + const keyFilename = './test/fixtures/external-account-cred.json'; + const scopes = mockGetAccessTokenAndProjectId(false); + scopes.push( + nock(url) + .get('/', undefined, { + reqheaders: { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }, + }) + .reply(200, data) + ); + + const auth = new GoogleAuth({keyFilename}); + const res = await auth.request({url}); + + assert.deepStrictEqual(res.data, data); + scopes.forEach(s => s.done()); + }); + }); + }); + + // Allows a client to be instantiated from a certificate, + // See: https://github.com/googleapis/google-auth-library-nodejs/issues/808 + it('allows client to be instantiated from PEM key file', async () => { + const auth = new GoogleAuth({ + keyFile: PEM_PATH, + clientOptions: { + scopes: 'http://foo', + email: 'foo@serviceaccount.com', + subject: 'bar@subjectaccount.com', + }, + }); + const jwt = await auth.getClient(); + const scope = createGTokenMock({access_token: 'initial-access-token'}); + const headers = await jwt.getRequestHeaders(); + assert.deepStrictEqual( + headers.Authorization, + 'Bearer initial-access-token' + ); + scope.done(); + assert.strictEqual('http://foo', (jwt as JWT).gtoken!.scope); + }); + it('allows client to be instantiated from p12 key file', async () => { + const auth = new GoogleAuth({ + keyFile: P12_PATH, + clientOptions: { + scopes: 'http://foo', + email: 'foo@serviceaccount.com', + subject: 'bar@subjectaccount.com', + }, + }); + const jwt = await auth.getClient(); + const scope = createGTokenMock({access_token: 'initial-access-token'}); + const headers = await jwt.getRequestHeaders(); + assert.deepStrictEqual( + headers.Authorization, + 'Bearer initial-access-token' + ); + scope.done(); + assert.strictEqual('http://foo', (jwt as JWT).gtoken!.scope); + }); + + // Allows a client to be instantiated from a certificate, + // See: https://github.com/googleapis/google-auth-library-nodejs/issues/808 + it('allows client to be instantiated from PEM key file', async () => { + const auth = new GoogleAuth({ + keyFile: PEM_PATH, + clientOptions: { + scopes: 'http://foo', + email: 'foo@serviceaccount.com', + subject: 'bar@subjectaccount.com', + }, + }); + const jwt = await auth.getClient(); + const scope = createGTokenMock({access_token: 'initial-access-token'}); + const headers = await jwt.getRequestHeaders(); + assert.deepStrictEqual( + headers.Authorization, + 'Bearer initial-access-token' + ); + scope.done(); + assert.strictEqual('http://foo', (jwt as JWT).gtoken!.scope); + }); + it('allows client to be instantiated from p12 key file', async () => { + const auth = new GoogleAuth({ + keyFile: P12_PATH, + clientOptions: { + scopes: 'http://foo', + email: 'foo@serviceaccount.com', + subject: 'bar@subjectaccount.com', + }, + }); + const jwt = await auth.getClient(); + const scope = createGTokenMock({access_token: 'initial-access-token'}); + const headers = await jwt.getRequestHeaders(); + assert.deepStrictEqual( + headers.Authorization, + 'Bearer initial-access-token' + ); + scope.done(); + assert.strictEqual('http://foo', (jwt as JWT).gtoken!.scope); }); // Allows a client to be instantiated from a certificate, diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts new file mode 100644 index 00000000..9543e8b7 --- /dev/null +++ b/test/test.identitypoolclient.ts @@ -0,0 +1,787 @@ +// Copyright 2021 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 * as assert from 'assert'; +import {describe, it} from 'mocha'; +import * as fs from 'fs'; +import * as nock from 'nock'; + +import { + IdentityPoolClient, + IdentityPoolClientOptions, +} from '../src/auth/identitypoolclient'; +import {StsSuccessfulResponse} from '../src/auth/stscredentials'; +import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; +import { + assertGaxiosResponsePresent, + getAudience, + getTokenUrl, + getServiceAccountImpersonationUrl, + mockGenerateAccessToken, + mockStsTokenExchange, +} from './externalclienthelper'; + +nock.disableNetConnect(); + +const ONE_HOUR_IN_SECS = 3600; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping +function escapeRegExp(str: string): string { + // $& means the whole matched string. + return str.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&'); +} + +describe('IdentityPoolClient', () => { + const fileSubjectToken = fs.readFileSync( + './test/fixtures/external-subject-token.txt', + 'utf-8' + ); + const audience = getAudience(); + const fileSourcedOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.txt', + }, + }; + const fileSourcedOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + fileSourcedOptions + ); + const jsonFileSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.json', + format: { + type: 'json', + subject_token_field_name: 'access_token', + }, + }, + }; + const jsonFileSourcedOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + jsonFileSourcedOptions + ); + const fileSourcedOptionsNotFound = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/not-found', + }, + }; + const metadataBaseUrl = 'http://169.254.169.254'; + const metadataPath = + '/metadata/identity/oauth2/token?' + 'api-version=2018-02-01&resource=abc'; + const metadataHeaders = { + Metadata: 'True', + other: 'some-value', + }; + const urlSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + url: `${metadataBaseUrl}${metadataPath}`, + headers: metadataHeaders, + }, + }; + const urlSourcedOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + urlSourcedOptions + ); + const jsonRespUrlSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + url: `${metadataBaseUrl}${metadataPath}`, + headers: metadataHeaders, + format: { + type: 'json', + subject_token_field_name: 'access_token', + }, + }, + }; + const jsonRespUrlSourcedOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + jsonRespUrlSourcedOptions + ); + const stsSuccessfulResponse: StsSuccessfulResponse = { + access_token: 'ACCESS_TOKEN', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: ONE_HOUR_IN_SECS, + scope: 'scope1 scope2', + }; + + it('should be a subclass of BaseExternalAccountClient', () => { + assert(IdentityPoolClient.prototype instanceof BaseExternalAccountClient); + }); + + describe('Constructor', () => { + it('should throw when invalid options are provided', () => { + const expectedError = new Error( + 'No valid Identity Pool "credential_source" provided' + ); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + other: 'invalid', + }, + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + + it('should throw on invalid credential_source.format.type', () => { + const expectedError = new Error('Invalid credential_source format "xml"'); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.txt', + format: { + type: 'xml', + }, + }, + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + + it('should throw on required credential_source.format.subject_token_field_name', () => { + const expectedError = new Error( + 'Missing subject_token_field_name for JSON credential_source format' + ); + const invalidOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.txt', + format: { + // json formats require the key where the subject_token is located. + type: 'json', + }, + }, + }; + + assert.throws(() => { + return new IdentityPoolClient(invalidOptions); + }, expectedError); + }); + + it('should not throw when valid file-sourced options are provided', () => { + assert.doesNotThrow(() => { + return new IdentityPoolClient(fileSourcedOptions); + }); + }); + + it('should not throw when valid url-sourced options are provided', () => { + assert.doesNotThrow(() => { + return new IdentityPoolClient(urlSourcedOptions); + }); + }); + + it('should not throw on headerless url-sourced options', () => { + const urlSourcedOptionsNoHeaders = Object.assign({}, urlSourcedOptions); + urlSourcedOptionsNoHeaders.credential_source = { + url: urlSourcedOptions.credential_source.url, + }; + assert.doesNotThrow(() => { + return new IdentityPoolClient(urlSourcedOptionsNoHeaders); + }); + }); + }); + + describe('for file-sourced subject tokens', () => { + describe('retrieveSubjectToken()', () => { + it('should resolve when the text file is found', async () => { + const client = new IdentityPoolClient(fileSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, fileSubjectToken); + }); + + it('should resolve when the json file is found', async () => { + const client = new IdentityPoolClient(jsonFileSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, fileSubjectToken); + }); + + it('should reject when the json subject_token_field_name is not found', async () => { + const expectedError = new Error( + 'Unable to parse the subject_token from the credential_source file' + ); + const invalidOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.json', + format: { + type: 'json', + subject_token_field_name: 'non-existent', + }, + }, + }; + const client = new IdentityPoolClient(invalidOptions); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + + it('should fail when the file is not found', async () => { + const invalidFile = fileSourcedOptionsNotFound.credential_source.file; + const client = new IdentityPoolClient(fileSourcedOptionsNotFound); + + await assert.rejects( + client.retrieveSubjectToken(), + new RegExp( + `The file at ${escapeRegExp(invalidFile)} does not exist, ` + + 'or it is not a file' + ) + ); + }); + + it('should fail when a folder is specified', async () => { + const invalidOptions = Object.assign({}, fileSourcedOptions); + invalidOptions.credential_source = { + // Specify a folder. + file: './test/fixtures', + }; + const invalidFile = fs.realpathSync( + invalidOptions.credential_source.file + ); + const client = new IdentityPoolClient(invalidOptions); + + await assert.rejects( + client.retrieveSubjectToken(), + new RegExp( + `The file at ${escapeRegExp(invalidFile)} does not exist, ` + + 'or it is not a file' + ) + ); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve on retrieveSubjectToken success for text format', async () => { + 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 loaded from file should be used. + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const client = new IdentityPoolClient(fileSourcedOptions); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should handle service account access token for text format', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + 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 loaded from file should be used. + subject_token: fileSubjectToken, + 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 client = new IdentityPoolClient(fileSourcedOptionsWithSA); + 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 resolve on retrieveSubjectToken success for json format', async () => { + 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 loaded from file should be used. + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const client = new IdentityPoolClient(jsonFileSourcedOptions); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should handle service account access token for json format', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + 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 loaded from file should be used. + subject_token: fileSubjectToken, + 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 client = new IdentityPoolClient(jsonFileSourcedOptionsWithSA); + 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 invalidFile = fileSourcedOptionsNotFound.credential_source.file; + const client = new IdentityPoolClient(fileSourcedOptionsNotFound); + + await assert.rejects( + client.getAccessToken(), + new RegExp( + `The file at ${invalidFile} does not exist, or it is not a file` + ) + ); + }); + }); + }); + + describe('for url-sourced subject tokens', () => { + describe('retrieveSubjectToken()', () => { + it('should resolve on text response success', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scope = nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, externalSubjectToken); + + const client = new IdentityPoolClient(urlSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, externalSubjectToken); + scope.done(); + }); + + it('should resolve on json response success', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const jsonResponse = { + access_token: externalSubjectToken, + }; + const scope = nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse); + + const client = new IdentityPoolClient(jsonRespUrlSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, externalSubjectToken); + scope.done(); + }); + + it('should reject when the json subject_token_field_name is not found', async () => { + const expectedError = new Error( + 'Unable to parse the subject_token from the credential_source URL' + ); + const invalidOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + url: `${metadataBaseUrl}${metadataPath}`, + headers: metadataHeaders, + format: { + type: 'json', + subject_token_field_name: 'non-existent', + }, + }, + }; + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const jsonResponse = { + access_token: externalSubjectToken, + }; + const scope = nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse); + const client = new IdentityPoolClient(invalidOptions); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + scope.done(); + }); + + it('should ignore headers when not provided', async () => { + // Create options without headers. + const urlSourcedOptionsNoHeaders = Object.assign({}, urlSourcedOptions); + urlSourcedOptionsNoHeaders.credential_source = { + url: urlSourcedOptions.credential_source.url, + }; + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const scope = nock(metadataBaseUrl) + .get(metadataPath) + .reply(200, externalSubjectToken); + + const client = new IdentityPoolClient(urlSourcedOptionsNoHeaders); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, externalSubjectToken); + scope.done(); + }); + + it('should reject with underlying on non-200 response', async () => { + const scope = nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(404); + + const client = new IdentityPoolClient(urlSourcedOptions); + + await assert.rejects(client.retrieveSubjectToken(), { + code: '404', + }); + scope.done(); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve on retrieveSubjectToken success for text format', 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', + }, + }, + ]) + ); + scopes.push( + nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, externalSubjectToken) + ); + + const client = new IdentityPoolClient(urlSourcedOptions); + 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 for text format', 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( + nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, externalSubjectToken), + 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 client = new IdentityPoolClient(urlSourcedOptionsWithSA); + 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 resolve on retrieveSubjectToken success for json format', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const jsonResponse = { + access_token: externalSubjectToken, + }; + 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', + }, + }, + ]) + ); + scopes.push( + nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse) + ); + + const client = new IdentityPoolClient(jsonRespUrlSourcedOptions); + 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 for json format', 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 jsonResponse = { + access_token: externalSubjectToken, + }; + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse), + 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 client = new IdentityPoolClient(jsonRespUrlSourcedOptionsWithSA); + 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 scope = nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(404); + + const client = new IdentityPoolClient(urlSourcedOptions); + + await assert.rejects(client.getAccessToken(), { + code: '404', + }); + scope.done(); + }); + }); + }); +}); diff --git a/test/test.index.ts b/test/test.index.ts index a68e00f3..f790a3ca 100644 --- a/test/test.index.ts +++ b/test/test.index.ts @@ -38,5 +38,8 @@ describe('index', () => { assert(gal.OAuth2Client); assert(gal.UserRefreshClient); assert(gal.GoogleAuth); + assert(gal.ExternalAccountClient); + assert(gal.IdentityPoolClient); + assert(gal.AwsClient); }); }); diff --git a/test/test.oauth2common.ts b/test/test.oauth2common.ts new file mode 100644 index 00000000..5f57649b --- /dev/null +++ b/test/test.oauth2common.ts @@ -0,0 +1,459 @@ +// Copyright 2021 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 {GaxiosOptions} from 'gaxios'; +import {describe, it} from 'mocha'; +import * as assert from 'assert'; +import * as querystring from 'querystring'; + +import {Headers} from '../src/auth/oauth2client'; +import { + ClientAuthentication, + OAuthClientAuthHandler, + getErrorFromOAuthErrorResponse, +} from '../src/auth/oauth2common'; + +/** Test class to test abstract class OAuthClientAuthHandler. */ +class TestOAuthClientAuthHandler extends OAuthClientAuthHandler { + testApplyClientAuthenticationOptions( + opts: GaxiosOptions, + bearerToken?: string + ) { + return this.applyClientAuthenticationOptions(opts, bearerToken); + } +} + +/** Custom error object for testing additional fields on an Error. */ +class CustomError extends Error { + public readonly code?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(message: string, stack?: any, code?: string) { + super(message); + this.name = 'CustomError'; + this.stack = stack; + this.code = code; + } +} + +describe('OAuthClientAuthHandler', () => { + const basicAuth: ClientAuthentication = { + confidentialClientType: 'basic', + clientId: 'username', + clientSecret: 'password', + }; + // Base64 encoding of "username:password" + const expectedBase64EncodedCred = 'dXNlcm5hbWU6cGFzc3dvcmQ='; + const basicAuthNoSecret: ClientAuthentication = { + confidentialClientType: 'basic', + clientId: 'username', + }; + // Base64 encoding of "username:" + const expectedBase64EncodedCredNoSecret = 'dXNlcm5hbWU6'; + const reqBodyAuth: ClientAuthentication = { + confidentialClientType: 'request-body', + clientId: 'username', + clientSecret: 'password', + }; + const reqBodyAuthNoSecret: ClientAuthentication = { + confidentialClientType: 'request-body', + clientId: 'username', + }; + + it('should not process request when no client authentication is used', () => { + const handler = new TestOAuthClientAuthHandler(); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(originalOptions, actualOptions); + }); + + it('should process request with basic client auth', () => { + const handler = new TestOAuthClientAuthHandler(basicAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + (expectedOptions.headers as Headers).Authorization = `Basic ${expectedBase64EncodedCred}`; + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should process request with secretless basic client auth', () => { + const handler = new TestOAuthClientAuthHandler(basicAuthNoSecret); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + (expectedOptions.headers as Headers).Authorization = `Basic ${expectedBase64EncodedCredNoSecret}`; + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should process GET (non-request-body) with basic client auth', () => { + const handler = new TestOAuthClientAuthHandler(basicAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + (expectedOptions.headers as Headers).Authorization = `Basic ${expectedBase64EncodedCred}`; + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + describe('with request-body client auth', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unsupportedMethods: any[] = [ + undefined, + 'GET', + 'DELETE', + 'TRACE', + 'OPTIONS', + 'HEAD', + ]; + unsupportedMethods.forEach(method => { + it(`should throw on requests with unsupported HTTP method ${method}`, () => { + const expectedError = new Error( + `${method || 'GET'} HTTP method does not support request-body ` + + 'client authentication' + ); + const handler = new TestOAuthClientAuthHandler(reqBodyAuth); + const originalOptions: GaxiosOptions = { + method, + url: 'https://www.example.com/path/to/api', + }; + + assert.throws(() => { + handler.testApplyClientAuthenticationOptions(originalOptions); + }, expectedError); + }); + }); + + it('should throw on unsupported content-types', () => { + const expectedError = new Error( + 'text/html content-types are not supported with request-body ' + + 'client authentication' + ); + const handler = new TestOAuthClientAuthHandler(reqBodyAuth); + const originalOptions: GaxiosOptions = { + headers: { + 'Content-Type': 'text/html', + }, + method: 'POST', + url: 'https://www.example.com/path/to/api', + }; + + assert.throws(() => { + handler.testApplyClientAuthenticationOptions(originalOptions); + }, expectedError); + }); + + it('should inject creds in non-empty json content', () => { + const handler = new TestOAuthClientAuthHandler(reqBodyAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + expectedOptions.data.client_id = reqBodyAuth.clientId; + expectedOptions.data.client_secret = reqBodyAuth.clientSecret; + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should inject secretless creds in json content', () => { + const handler = new TestOAuthClientAuthHandler(reqBodyAuthNoSecret); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + expectedOptions.data.client_id = reqBodyAuthNoSecret.clientId; + expectedOptions.data.client_secret = ''; + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should inject creds in empty json content', () => { + const handler = new TestOAuthClientAuthHandler(reqBodyAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + expectedOptions.data = { + client_id: reqBodyAuth.clientId, + client_secret: reqBodyAuth.clientSecret, + }; + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should inject creds in non-empty x-www-form-urlencoded content', () => { + const handler = new TestOAuthClientAuthHandler(reqBodyAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + // Handling of headers should be case insensitive. + 'content-Type': 'application/x-www-form-urlencoded', + }, + data: querystring.stringify({key1: 'value1', key2: 'value2'}), + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + expectedOptions.data = querystring.stringify({ + key1: 'value1', + key2: 'value2', + client_id: reqBodyAuth.clientId, + client_secret: reqBodyAuth.clientSecret, + }); + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should inject secretless creds in x-www-form-urlencoded content', () => { + const handler = new TestOAuthClientAuthHandler(reqBodyAuthNoSecret); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: querystring.stringify({key1: 'value1', key2: 'value2'}), + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + expectedOptions.data = querystring.stringify({ + key1: 'value1', + key2: 'value2', + client_id: reqBodyAuth.clientId, + client_secret: '', + }); + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should inject creds in empty x-www-form-urlencoded content', () => { + const handler = new TestOAuthClientAuthHandler(reqBodyAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + expectedOptions.data = querystring.stringify({ + client_id: reqBodyAuth.clientId, + client_secret: reqBodyAuth.clientSecret, + }); + + handler.testApplyClientAuthenticationOptions(actualOptions); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + }); + + it('should process request with bearer token when provided', () => { + const bearerToken = 'BEARER_TOKEN'; + const handler = new TestOAuthClientAuthHandler(); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + const expectedOptions = Object.assign({}, originalOptions); + (expectedOptions.headers as Headers).Authorization = `Bearer ${bearerToken}`; + + handler.testApplyClientAuthenticationOptions(actualOptions, bearerToken); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should prioritize bearer token over basic auth', () => { + const bearerToken = 'BEARER_TOKEN'; + const handler = new TestOAuthClientAuthHandler(basicAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + // Expected options should have bearer token in header. + const expectedOptions = Object.assign({}, originalOptions); + (expectedOptions.headers as Headers).Authorization = `Bearer ${bearerToken}`; + + handler.testApplyClientAuthenticationOptions(actualOptions, bearerToken); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); + + it('should prioritize bearer token over request body', () => { + const bearerToken = 'BEARER_TOKEN'; + const handler = new TestOAuthClientAuthHandler(reqBodyAuth); + const originalOptions: GaxiosOptions = { + url: 'https://www.example.com/path/to/api', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + data: { + key1: 'value1', + key2: 'value2', + }, + }; + const actualOptions = Object.assign({}, originalOptions); + // Expected options should have bearer token in header. + const expectedOptions = Object.assign({}, originalOptions); + (expectedOptions.headers as Headers).Authorization = `Bearer ${bearerToken}`; + + handler.testApplyClientAuthenticationOptions(actualOptions, bearerToken); + assert.deepStrictEqual(expectedOptions, actualOptions); + }); +}); + +describe('getErrorFromOAuthErrorResponse', () => { + it('should create expected error with code, description and uri', () => { + const resp = { + error: 'unsupported_grant_type', + error_description: 'The provided grant_type is unsupported', + error_uri: 'https://tools.ietf.org/html/rfc6749', + }; + const error = getErrorFromOAuthErrorResponse(resp); + assert.strictEqual( + error.message, + `Error code ${resp.error}: ${resp.error_description} ` + + `- ${resp.error_uri}` + ); + }); + + it('should create expected error with code and description', () => { + const resp = { + error: 'unsupported_grant_type', + error_description: 'The provided grant_type is unsupported', + }; + const error = getErrorFromOAuthErrorResponse(resp); + assert.strictEqual( + error.message, + `Error code ${resp.error}: ${resp.error_description}` + ); + }); + + it('should create expected error with code only', () => { + const resp = { + error: 'unsupported_grant_type', + }; + const error = getErrorFromOAuthErrorResponse(resp); + assert.strictEqual(error.message, `Error code ${resp.error}`); + }); + + it('should preserve the original error properties', () => { + const originalError = new CustomError( + 'Original error message', + 'Error stack', + '123456' + ); + const resp = { + error: 'unsupported_grant_type', + error_description: 'The provided grant_type is unsupported', + error_uri: 'https://tools.ietf.org/html/rfc6749', + }; + const expectedError = new CustomError( + `Error code ${resp.error}: ${resp.error_description} ` + + `- ${resp.error_uri}`, + 'Error stack', + '123456' + ); + + const actualError = getErrorFromOAuthErrorResponse(resp, originalError); + assert.strictEqual(actualError.message, expectedError.message); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.strictEqual((actualError as any).code, expectedError.code); + assert.strictEqual(actualError.name, expectedError.name); + assert.strictEqual(actualError.stack, expectedError.stack); + }); +}); diff --git a/test/test.stscredentials.ts b/test/test.stscredentials.ts new file mode 100644 index 00000000..72e17119 --- /dev/null +++ b/test/test.stscredentials.ts @@ -0,0 +1,403 @@ +// Copyright 2021 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 * as assert from 'assert'; +import {describe, it, afterEach} from 'mocha'; +import * as qs from 'querystring'; +import * as nock from 'nock'; +import {createCrypto} from '../src/crypto/crypto'; +import { + StsCredentials, + StsCredentialsOptions, + StsSuccessfulResponse, +} from '../src/auth/stscredentials'; +import { + ClientAuthentication, + OAuthErrorResponse, + getErrorFromOAuthErrorResponse, +} from '../src/auth/oauth2common'; + +nock.disableNetConnect(); + +describe('StsCredentials', () => { + const crypto = createCrypto(); + const baseUrl = 'https://example.com'; + const path = '/token.oauth2'; + const tokenExchangeEndpoint = `${baseUrl}${path}`; + const basicAuth: ClientAuthentication = { + confidentialClientType: 'basic', + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + }; + const requestBodyAuth: ClientAuthentication = { + confidentialClientType: 'request-body', + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + }; + // Full STS credentials options, useful to test that all supported + // parameters are handled correctly. + const stsCredentialsOptions: StsCredentialsOptions = { + grantType: 'urn:ietf:params:oauth:grant-type:token-exchange', + resource: 'https://api.example.com/', + audience: 'urn:example:cooperation-context', + scope: ['scope1', 'scope2'], + requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token', + subjectToken: 'HEADER.SUBJECT_TOKEN_PAYLOAD.SIGNATURE', + subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + actingParty: { + actorToken: 'HEADER.ACTOR_TOKEN_PAYLOAD.SIGNATURE', + actorTokenType: 'urn:ietf:params:oauth:token-type:jwt', + }, + }; + // Partial STS credentials options, useful to test that optional unspecified + // parameters are handled correctly. + const partialStsCredentialsOptions: StsCredentialsOptions = { + grantType: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: 'urn:example:cooperation-context', + requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token', + subjectToken: 'HEADER.SUBJECT_TOKEN_PAYLOAD.SIGNATURE', + subjectTokenType: 'urn:ietf:params:oauth:token-type:jwt', + }; + const stsSuccessfulResponse: StsSuccessfulResponse = { + access_token: 'ACCESS_TOKEN', + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'scope1 scope2', + }; + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid subject token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + + function assertGaxiosResponsePresent(resp: StsSuccessfulResponse) { + const gaxiosResponse = resp.res || {}; + assert('data' in gaxiosResponse && 'status' in gaxiosResponse); + } + + function mockStsTokenExchange( + statusCode = 200, + response: StsSuccessfulResponse | OAuthErrorResponse, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: {[key: string]: any}, + additionalHeaders?: {[key: string]: string} + ): nock.Scope { + const headers = Object.assign( + { + 'content-type': 'application/x-www-form-urlencoded', + }, + additionalHeaders || {} + ); + return nock(baseUrl) + .post(path, qs.stringify(request), { + reqheaders: headers, + }) + .reply(statusCode, response); + } + + afterEach(() => { + nock.cleanAll(); + }); + + describe('exchangeToken()', () => { + const additionalHeaders = { + 'x-client-version': '0.1.2', + }; + const options = { + additional: { + 'non-standard': ['options'], + other: 'some-value', + }, + }; + const expectedRequest = { + grant_type: stsCredentialsOptions.grantType, + resource: stsCredentialsOptions.resource, + audience: stsCredentialsOptions.audience, + scope: stsCredentialsOptions.scope?.join(' '), + requested_token_type: stsCredentialsOptions.requestedTokenType, + subject_token: stsCredentialsOptions.subjectToken, + subject_token_type: stsCredentialsOptions.subjectTokenType, + actor_token: stsCredentialsOptions.actingParty?.actorToken, + actor_token_type: stsCredentialsOptions.actingParty?.actorTokenType, + options: JSON.stringify(options), + }; + const expectedPartialRequest = { + grant_type: stsCredentialsOptions.grantType, + audience: stsCredentialsOptions.audience, + requested_token_type: stsCredentialsOptions.requestedTokenType, + subject_token: stsCredentialsOptions.subjectToken, + subject_token_type: stsCredentialsOptions.subjectTokenType, + }; + const expectedRequestWithCreds = Object.assign({}, expectedRequest, { + client_id: requestBodyAuth.clientId, + client_secret: requestBodyAuth.clientSecret, + }); + const expectedPartialRequestWithCreds = Object.assign( + {}, + expectedPartialRequest, + { + client_id: requestBodyAuth.clientId, + client_secret: requestBodyAuth.clientSecret, + } + ); + + describe('without client authentication', () => { + it('should handle successful full request', async () => { + const scope = mockStsTokenExchange( + 200, + stsSuccessfulResponse, + expectedRequest, + additionalHeaders + ); + const stsCredentials = new StsCredentials(tokenExchangeEndpoint); + + const resp = await stsCredentials.exchangeToken( + stsCredentialsOptions, + additionalHeaders, + options + ); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(resp); + delete resp.res; + assert.deepStrictEqual(resp, stsSuccessfulResponse); + scope.done(); + }); + + it('should handle successful partial request', async () => { + const scope = mockStsTokenExchange( + 200, + stsSuccessfulResponse, + expectedPartialRequest + ); + const stsCredentials = new StsCredentials(tokenExchangeEndpoint); + + const resp = await stsCredentials.exchangeToken( + partialStsCredentialsOptions + ); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(resp); + delete resp.res; + assert.deepStrictEqual(resp, stsSuccessfulResponse); + scope.done(); + }); + + it('should handle non-200 response', async () => { + const scope = mockStsTokenExchange( + 400, + errorResponse, + expectedRequest, + additionalHeaders + ); + const expectedError = getErrorFromOAuthErrorResponse(errorResponse); + const stsCredentials = new StsCredentials(tokenExchangeEndpoint); + + await assert.rejects( + stsCredentials.exchangeToken( + stsCredentialsOptions, + additionalHeaders, + options + ), + expectedError + ); + scope.done(); + }); + + it('should handle request timeout', async () => { + const scope = nock(baseUrl) + .post(path, qs.stringify(expectedRequest), { + reqheaders: { + 'content-type': 'application/x-www-form-urlencoded', + }, + }) + .replyWithError({code: 'ETIMEDOUT'}); + const stsCredentials = new StsCredentials(tokenExchangeEndpoint); + + await assert.rejects( + stsCredentials.exchangeToken( + stsCredentialsOptions, + additionalHeaders, + options + ), + { + code: 'ETIMEDOUT', + } + ); + scope.done(); + }); + }); + + describe('with basic client authentication', () => { + const creds = `${basicAuth.clientId}:${basicAuth.clientSecret}`; + it('should handle successful full request', async () => { + const scope = mockStsTokenExchange( + 200, + stsSuccessfulResponse, + expectedRequest, + Object.assign( + { + Authorization: `Basic ${crypto.encodeBase64StringUtf8(creds)}`, + }, + additionalHeaders + ) + ); + const stsCredentials = new StsCredentials( + tokenExchangeEndpoint, + basicAuth + ); + + const resp = await stsCredentials.exchangeToken( + stsCredentialsOptions, + additionalHeaders, + options + ); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(resp); + delete resp.res; + assert.deepStrictEqual(resp, stsSuccessfulResponse); + scope.done(); + }); + + it('should handle successful partial request', async () => { + const scope = mockStsTokenExchange( + 200, + stsSuccessfulResponse, + expectedPartialRequest, + { + Authorization: `Basic ${crypto.encodeBase64StringUtf8(creds)}`, + } + ); + const stsCredentials = new StsCredentials( + tokenExchangeEndpoint, + basicAuth + ); + + const resp = await stsCredentials.exchangeToken( + partialStsCredentialsOptions + ); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(resp); + delete resp.res; + assert.deepStrictEqual(resp, stsSuccessfulResponse); + scope.done(); + }); + + it('should handle non-200 response', async () => { + const expectedError = getErrorFromOAuthErrorResponse(errorResponse); + const scope = mockStsTokenExchange( + 400, + errorResponse, + expectedRequest, + Object.assign( + { + Authorization: `Basic ${crypto.encodeBase64StringUtf8(creds)}`, + }, + additionalHeaders + ) + ); + const stsCredentials = new StsCredentials( + tokenExchangeEndpoint, + basicAuth + ); + + await assert.rejects( + stsCredentials.exchangeToken( + stsCredentialsOptions, + additionalHeaders, + options + ), + expectedError + ); + scope.done(); + }); + }); + + describe('with request-body client authentication', () => { + it('should handle successful full request', async () => { + const scope = mockStsTokenExchange( + 200, + stsSuccessfulResponse, + expectedRequestWithCreds, + additionalHeaders + ); + const stsCredentials = new StsCredentials( + tokenExchangeEndpoint, + requestBodyAuth + ); + + const resp = await stsCredentials.exchangeToken( + stsCredentialsOptions, + additionalHeaders, + options + ); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(resp); + delete resp.res; + assert.deepStrictEqual(resp, stsSuccessfulResponse); + scope.done(); + }); + + it('should handle successful partial request', async () => { + const scope = mockStsTokenExchange( + 200, + stsSuccessfulResponse, + expectedPartialRequestWithCreds + ); + const stsCredentials = new StsCredentials( + tokenExchangeEndpoint, + requestBodyAuth + ); + + const resp = await stsCredentials.exchangeToken( + partialStsCredentialsOptions + ); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(resp); + delete resp.res; + assert.deepStrictEqual(resp, stsSuccessfulResponse); + scope.done(); + }); + + it('should handle non-200 response', async () => { + const expectedError = getErrorFromOAuthErrorResponse(errorResponse); + const scope = mockStsTokenExchange( + 400, + errorResponse, + expectedRequestWithCreds, + additionalHeaders + ); + const stsCredentials = new StsCredentials( + tokenExchangeEndpoint, + requestBodyAuth + ); + + await assert.rejects( + stsCredentials.exchangeToken( + stsCredentialsOptions, + additionalHeaders, + options + ), + expectedError + ); + scope.done(); + }); + }); + }); +});