From 3f1368ddbf4319d86386d95672dd9a04c177f9d1 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 6 Aug 2020 17:06:57 -0700 Subject: [PATCH 01/25] feat: defines internal utils for OAuth client Auth and error handling (#1022) This defines internal utilities to handle: - OAuth client authentication - Standard OAuth response parsing into native Javascript errors. --- src/auth/oauth2common.ts | 206 +++++++++++++++++++ test/test.oauth2common.ts | 421 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 627 insertions(+) create mode 100644 src/auth/oauth2common.ts create mode 100644 test/test.oauth2common.ts diff --git a/src/auth/oauth2common.ts b/src/auth/oauth2common.ts new file mode 100644 index 00000000..e9a03c94 --- /dev/null +++ b/src/auth/oauth2common.ts @@ -0,0 +1,206 @@ +// 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 {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. + * @return The converted native Error object. + */ +export function getErrorFromOAuthErrorResponse( + resp: OAuthErrorResponse +): 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}`; + } + return new Error(message); +} diff --git a/test/test.oauth2common.ts b/test/test.oauth2common.ts new file mode 100644 index 00000000..e9ecaca7 --- /dev/null +++ b/test/test.oauth2common.ts @@ -0,0 +1,421 @@ +// 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 {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); + } +} + +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}`); + }); +}); From 7f8b1249c2f5467d711a44a2772daa386b1c7231 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Mon, 10 Aug 2020 20:09:11 -0700 Subject: [PATCH 02/25] feat: implements the OAuth token exchange spec based on rfc8693 (#1026) --- README.md | 12 +- samples/README.md | 22 +- ...ens-cloudrun.js => idtokens-serverless.js} | 22 +- samples/test/jwt.test.js | 9 +- src/auth/oauth2common.ts | 27 +- src/auth/stscredentials.ts | 228 ++++++++++ synth.metadata | 2 +- test/test.oauth2common.ts | 38 ++ test/test.stscredentials.ts | 403 ++++++++++++++++++ 9 files changed, 730 insertions(+), 33 deletions(-) rename samples/{idtokens-cloudrun.js => idtokens-serverless.js} (71%) create mode 100644 src/auth/stscredentials.ts create mode 100644 test/test.stscredentials.ts diff --git a/README.md b/README.md index fdaa7381..0c2d03cf 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ main().catch(console.error); ## Working with ID Tokens ### Fetching ID Tokens -If your application is running behind Cloud Run, or using Cloud Identity-Aware +If your application is running on Cloud Run or Cloud Functions, or using Cloud Identity-Aware Proxy (IAP), you will need to fetch an ID token to access your application. For this, use the method `getIdTokenClient` on the `GoogleAuth` client. @@ -343,12 +343,16 @@ For invoking Cloud Run services, your service account will need the [`Cloud Run Invoker`](https://cloud.google.com/run/docs/authenticating/service-to-service) IAM permission. +For invoking Cloud Functions, your service account will need the +[`Function Invoker`](https://cloud.google.com/functions/docs/securing/authenticating#function-to-function) +IAM permission. + ``` js -// Make a request to a protected Cloud Run +// Make a request to a protected Cloud Run service. const {GoogleAuth} = require('google-auth-library'); async function main() { - const url = 'https://cloud-run-url.com'; + const url = 'https://cloud-run-1234-uc.a.run.app'; const auth = new GoogleAuth(); const client = auth.getIdTokenClient(url); const res = await client.request({url}); @@ -358,7 +362,7 @@ async function main() { main().catch(console.error); ``` -A complete example can be found in [`samples/idtokens-cloudrun.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-cloudrun.js). +A complete example can be found in [`samples/idtokens-serverless.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-serverless.js). For invoking Cloud Identity-Aware Proxy, you will need to pass the Client ID used when you set up your protected resource as the target audience. diff --git a/samples/README.md b/samples/README.md index 9e6f6298..40571df3 100644 --- a/samples/README.md +++ b/samples/README.md @@ -16,8 +16,8 @@ * [Compute](#compute) * [Credentials](#credentials) * [Headers](#headers) - * [ID Tokens for Cloud Run](#id-tokens-for-cloud-run) * [ID Tokens for Identity-Aware Proxy (IAP)](#id-tokens-for-identity-aware-proxy-iap) + * [ID Tokens for Serverless](#id-tokens-for-serverless) * [Jwt](#jwt) * [Keepalive](#keepalive) * [Keyfile](#keyfile) @@ -110,18 +110,18 @@ __Usage:__ -### ID Tokens for Cloud Run +### ID Tokens for Identity-Aware Proxy (IAP) -Requests a Cloud Run URL with an ID Token. +Requests an IAP-protected resource with an ID Token. -View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-cloudrun.js). +View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-iap.js). -[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-cloudrun.js,samples/README.md) +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-iap.js,samples/README.md) __Usage:__ -`node idtokens-cloudrun.js []` +`node idtokens-iap.js ` ----- @@ -129,18 +129,18 @@ __Usage:__ -### ID Tokens for Identity-Aware Proxy (IAP) +### ID Tokens for Serverless -Requests an IAP-protected resource with an ID Token. +Requests a Cloud Run or Cloud Functions URL with an ID Token. -View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-iap.js). +View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-serverless.js). -[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-iap.js,samples/README.md) +[![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-serverless.js,samples/README.md) __Usage:__ -`node idtokens-iap.js ` +`node idtokens-serverless.js []` ----- diff --git a/samples/idtokens-cloudrun.js b/samples/idtokens-serverless.js similarity index 71% rename from samples/idtokens-cloudrun.js rename to samples/idtokens-serverless.js index 213f3567..c76a8605 100644 --- a/samples/idtokens-cloudrun.js +++ b/samples/idtokens-serverless.js @@ -12,9 +12,9 @@ // limitations under the License. // sample-metadata: -// title: ID Tokens for Cloud Run -// description: Requests a Cloud Run URL with an ID Token. -// usage: node idtokens-cloudrun.js [] +// title: ID Tokens for Serverless +// description: Requests a Cloud Run or Cloud Functions URL with an ID Token. +// usage: node idtokens-serverless.js [] 'use strict'; @@ -22,23 +22,23 @@ function main( url = 'https://service-1234-uc.a.run.app', targetAudience = null ) { - // [START google_auth_idtoken_cloudrun] + // [START google_auth_idtoken_serverless] + // [START run_service_to_service_auth] + // [START functions_bearer_token] /** * TODO(developer): Uncomment these variables before running the sample. */ - // const url = 'https://YOUR_CLOUD_RUN_URL.run.app'; + // const url = 'https://TARGET_URL'; const {GoogleAuth} = require('google-auth-library'); const auth = new GoogleAuth(); async function request() { if (!targetAudience) { - // Use the request URL hostname as the target audience for Cloud Run requests + // Use the request URL hostname as the target audience for requests. const {URL} = require('url'); targetAudience = new URL(url).origin; } - console.info( - `request Cloud Run ${url} with target audience ${targetAudience}` - ); + console.info(`request ${url} with target audience ${targetAudience}`); const client = await auth.getIdTokenClient(targetAudience); const res = await client.request({url}); console.info(res.data); @@ -48,7 +48,9 @@ function main( console.error(err.message); process.exitCode = 1; }); - // [END google_auth_idtoken_cloudrun] + // [END functions_bearer_token] + // [END run_service_to_service_auth] + // [END google_auth_idtoken_serverless] } const args = process.argv.slice(2); diff --git a/samples/test/jwt.test.js b/samples/test/jwt.test.js index 42c2b226..e87792bd 100644 --- a/samples/test/jwt.test.js +++ b/samples/test/jwt.test.js @@ -65,17 +65,16 @@ describe('samples', () => { }); it('should fetch ID token for Cloud Run', async () => { - // process.env.CLOUD_RUN_URL should be a cloud run container, protected with - // IAP, running gcr.io/cloudrun/hello: + // process.env.CLOUD_RUN_URL should be a cloud run service running + // gcr.io/cloudrun/hello: const url = process.env.CLOUD_RUN_URL || 'https://hello-rftcw63abq-uc.a.run.app'; - const output = execSync(`node idtokens-cloudrun ${url}`); + const output = execSync(`node idtokens-serverless ${url}`); assert.match(output, /What's next?/); }); it('should fetch ID token for IAP', async () => { - // process.env.CLOUD_RUN_URL should be a cloud run container, protected with - // IAP, running gcr.io/cloudrun/hello: + // process.env.IAP_URL should be an App Engine app, protected with IAP: const url = process.env.IAP_URL || 'https://nodejs-docs-samples-iap.appspot.com'; const targetAudience = diff --git a/src/auth/oauth2common.ts b/src/auth/oauth2common.ts index e9a03c94..ad4db99e 100644 --- a/src/auth/oauth2common.ts +++ b/src/auth/oauth2common.ts @@ -186,10 +186,13 @@ export abstract class OAuthClientAuthHandler { /** * 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 + resp: OAuthErrorResponse, + err?: Error ): Error { // Error response. const errorCode = resp.error; @@ -202,5 +205,25 @@ export function getErrorFromOAuthErrorResponse( if (typeof errorUri !== 'undefined') { message += ` - ${errorUri}`; } - return new Error(message); + 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..64c65e4f --- /dev/null +++ b/src/auth/stscredentials.ts @@ -0,0 +1,228 @@ +// 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 {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/synth.metadata b/synth.metadata index eaf9d3ce..4dcb2a4b 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,7 +4,7 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "a292945146b95bc254aa5576db13536e27f35554" + "sha": "11b0cb735c47cecef939b0af934747cf9cd29e9a" } }, { diff --git a/test/test.oauth2common.ts b/test/test.oauth2common.ts index e9ecaca7..d9e0dd62 100644 --- a/test/test.oauth2common.ts +++ b/test/test.oauth2common.ts @@ -34,6 +34,18 @@ class TestOAuthClientAuthHandler extends OAuthClientAuthHandler { } } +/** 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', @@ -418,4 +430,30 @@ describe('getErrorFromOAuthErrorResponse', () => { 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..7efb03ea --- /dev/null +++ b/test/test.stscredentials.ts @@ -0,0 +1,403 @@ +// 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 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(); + }); + }); + }); +}); From 1174e4ac8241ec05678e1c14ae70755f5a23fef8 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Fri, 14 Aug 2020 15:07:57 -0700 Subject: [PATCH 03/25] feat: defines ExternalAccountClient abstract class for external_account credentials (#1030) --- .kokoro/populate-secrets.sh | 1 - .kokoro/release/docs-devsite.sh | 5 + CHANGELOG.md | 7 + package.json | 2 +- src/auth/externalclient.ts | 334 +++++++++ synth.metadata | 4 +- test/test.externalclient.ts | 1122 +++++++++++++++++++++++++++++++ 7 files changed, 1471 insertions(+), 4 deletions(-) create mode 100644 src/auth/externalclient.ts create mode 100644 test/test.externalclient.ts diff --git a/.kokoro/populate-secrets.sh b/.kokoro/populate-secrets.sh index e6ce8200..6f9d2288 100755 --- a/.kokoro/populate-secrets.sh +++ b/.kokoro/populate-secrets.sh @@ -32,7 +32,6 @@ do --volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR} \ gcr.io/google.com/cloudsdktool/cloud-sdk \ secrets versions access latest \ - --credential-file-override=${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json \ --project cloud-devrel-kokoro-resources \ --secret $key > \ "$SECRET_LOCATION/$key" diff --git a/.kokoro/release/docs-devsite.sh b/.kokoro/release/docs-devsite.sh index 3b93137d..fa089cf2 100755 --- a/.kokoro/release/docs-devsite.sh +++ b/.kokoro/release/docs-devsite.sh @@ -36,6 +36,11 @@ NAME=$(cat .repo-metadata.json | json name) mkdir ./_devsite cp ./yaml/$NAME/* ./_devsite + +# Delete SharePoint item, see https://github.com/microsoft/rushstack/issues/1229 +sed -i -e '1,3d' ./yaml/toc.yml +sed -i -e 's/^ //' ./yaml/toc.yml + cp ./yaml/toc.yml ./_devsite/toc.yml # create docs.metadata, based on package.json and .repo-metadata.json. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5efe0e..989e781a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions +### [6.0.7](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.6...v6.0.7) (2020-08-11) + + +### Bug Fixes + +* migrate token info API to not pass token in query string ([#991](https://www.github.com/googleapis/google-auth-library-nodejs/issues/991)) ([a7e5701](https://www.github.com/googleapis/google-auth-library-nodejs/commit/a7e5701a8394d79fe93d28794467747a23cf9ff4)) + ### [6.0.6](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.5...v6.0.6) (2020-07-30) diff --git a/package.json b/package.json index a03bb699..836568b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "6.0.6", + "version": "6.0.7", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { diff --git a/src/auth/externalclient.ts b/src/auth/externalclient.ts new file mode 100644 index 00000000..4bb9c3ca --- /dev/null +++ b/src/auth/externalclient.ts @@ -0,0 +1,334 @@ +// 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 { + 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. */ +const EXTERNAL_ACCOUNT_TYPE = 'external_account'; + +/** + * Base external account credentials json interface. + */ +export interface ExternalAccountClientOptions { + 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; +} + +/** + * 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 ExternalAccountClient 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; + private eagerRefreshThresholdMillis: number; + private forceRefreshOnFailure: boolean; + private readonly audience: string; + private readonly subjectTokenType: string; + private readonly stsCredential: sts.StsCredentials; + + /** + * Instantiate an ExternalAccountClient 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: ExternalAccountClientOptions, + 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; + // 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; + } + + /** + * 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); + } + } + + /** + * 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 r2: GaxiosResponse; + try { + const r = await this.getRequestHeaders(); + opts.headers = opts.headers || {}; + if (r && r['x-goog-user-project']) { + opts.headers['x-goog-user-project'] = r['x-goog-user-project']; + } + if (r && r.Authorization) { + opts.headers.Authorization = r.Authorization; + } + r2 = 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 this.requestAsync(opts, true); + } + } + throw e; + } + return r2; + } + + /** + * 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. + * @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, + scope: this.getScopesArray(), + }; + + // Exchange the external credentials for a GCP access token. + const stsResponse = await this.stsCredential.exchangeToken( + stsCredentialsOptions + ); + // 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 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[] | undefined { + // Since scopes can be provided as string or array, the type should + // be normalized. + return typeof this.scopes === 'string' ? [this.scopes] : this.scopes; + } +} diff --git a/synth.metadata b/synth.metadata index 4dcb2a4b..98567439 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "11b0cb735c47cecef939b0af934747cf9cd29e9a" + "sha": "b976c8aabac409e96aadaf3a515a03c5da8eda29" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "89d431fb2975fc4e0ed24995a6e6dfc8ff4c24fa" + "sha": "bd0deaa1113b588d70449535ab9cbf0f2bd0e72f" } } ] diff --git a/test/test.externalclient.ts b/test/test.externalclient.ts new file mode 100644 index 00000000..849cc5c8 --- /dev/null +++ b/test/test.externalclient.ts @@ -0,0 +1,1122 @@ +// 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 assert from 'assert'; +import {describe, it, afterEach} from 'mocha'; +import * as qs from 'querystring'; +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, + ExternalAccountClient, +} from '../src/auth/externalclient'; +import { + OAuthErrorResponse, + getErrorFromOAuthErrorResponse, +} from '../src/auth/oauth2common'; +import {GetAccessTokenResponse} from '../src/auth/oauth2client'; +import {GaxiosError} from 'gaxios'; + +nock.disableNetConnect(); + +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 SampleResponse { + foo: string; + bar: number; +} + +/** Test class to test abstract class ExternalAccountClient. */ +class TestExternalAccountClient extends ExternalAccountClient { + private counter = 0; + + async retrieveSubjectToken(): Promise { + // Increment subject_token counter each time this is called. + return `subject_token_${this.counter++}`; + } +} + +describe('ExternalAccountClient', () => { + let clock: sinon.SinonFakeTimers; + const ONE_HOUR_IN_SECS = 3600; + const crypto = createCrypto(); + const baseUrl = 'https://sts.googleapis.com'; + const path = '/v1/token'; + const projectNumber = '123456'; + const poolId = 'POOL_ID'; + const providerId = 'PROVIDER_ID'; + const audience = + `//iam.googleapis.com/project/${projectNumber}` + + `/locations/global/workloadIdentityPools/${poolId}/` + + `providers/${providerId}`; + const externalAccountOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: `${baseUrl}${path}`, + 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: `${baseUrl}${path}`, + 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', + }; + + function assertGaxiosResponsePresent(resp: GetAccessTokenResponse) { + const gaxiosResponse = resp.res || {}; + assert('data' in gaxiosResponse && 'status' in gaxiosResponse); + } + + 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; + } + + 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); + }); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve with the expected GetAccessTokenResponse', 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 eagerRefreshThresholdMillis when provided', 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 client_id/secret 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('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 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 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 + ); + }); + }); +}); From 424755cc5a90d148c5db09f1284687cea8d8f96b Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 18 Aug 2020 11:14:23 -0700 Subject: [PATCH 04/25] feat: adds service account impersonation to `ExternalAccountClient` (#1041) --- .kokoro/release/publish.cfg | 2 +- CHANGELOG.md | 7 + package.json | 2 +- samples/package.json | 2 +- src/auth/externalclient.ts | 82 ++- synth.metadata | 4 +- test/test.externalclient.ts | 1300 ++++++++++++++++++++++++++--------- 7 files changed, 1053 insertions(+), 346 deletions(-) diff --git a/.kokoro/release/publish.cfg b/.kokoro/release/publish.cfg index 3bed518a..e63ee55f 100644 --- a/.kokoro/release/publish.cfg +++ b/.kokoro/release/publish.cfg @@ -61,7 +61,7 @@ build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" # Configure the docker image for kokoro-trampoline. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/node:8-user" + value: "gcr.io/cloud-devrel-kokoro-resources/node:12-user" } env_vars: { diff --git a/CHANGELOG.md b/CHANGELOG.md index 989e781a..daba9d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions +### [6.0.8](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.7...v6.0.8) (2020-08-13) + + +### Bug Fixes + +* **deps:** roll back dependency google-auth-library to ^6.0.6 ([#1033](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1033)) ([eb54ee9](https://www.github.com/googleapis/google-auth-library-nodejs/commit/eb54ee9369d9e5a01d164ccf7f826858d44827fd)) + ### [6.0.7](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.6...v6.0.7) (2020-08-11) diff --git a/package.json b/package.json index 836568b0..f5f04166 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "6.0.7", + "version": "6.0.8", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { diff --git a/samples/package.json b/samples/package.json index 56f31579..57e6e542 100644 --- a/samples/package.json +++ b/samples/package.json @@ -12,7 +12,7 @@ }, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^6.0.6", + "google-auth-library": "^6.0.8", "node-fetch": "^2.3.0", "opn": "^5.3.0", "server-destroy": "^1.0.1" diff --git a/src/auth/externalclient.ts b/src/auth/externalclient.ts index 4bb9c3ca..3a1e0690 100644 --- a/src/auth/externalclient.ts +++ b/src/auth/externalclient.ts @@ -59,6 +59,17 @@ export interface ExternalAccountClientOptions { 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; +} + /** * Internal interface for tracking the access token expiration time. */ @@ -87,6 +98,7 @@ export abstract class ExternalAccountClient extends AuthClient { private forceRefreshOnFailure: boolean; private readonly audience: string; private readonly subjectTokenType: string; + private readonly serviceAccountImpersonationUrl?: string; private readonly stsCredential: sts.StsCredentials; /** @@ -123,6 +135,8 @@ export abstract class ExternalAccountClient extends AuthClient { 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. @@ -267,6 +281,9 @@ export abstract class ExternalAccountClient extends AuthClient { * 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 { @@ -279,19 +296,34 @@ export abstract class ExternalAccountClient extends AuthClient { requestedTokenType: STS_REQUEST_TOKEN_TYPE, subjectToken, subjectTokenType: this.subjectTokenType, - scope: this.getScopesArray(), + // 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 ); - // 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, - }; + + 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 = {}; @@ -310,6 +342,42 @@ export abstract class ExternalAccountClient extends AuthClient { return this.cachedAccessToken; } + /** + * 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< + IamGenerateAccessTokenResponse + >(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. diff --git a/synth.metadata b/synth.metadata index 98567439..0d36d211 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "b976c8aabac409e96aadaf3a515a03c5da8eda29" + "sha": "4830a5308d780822e884b0fb98fa605d3e7dc77b" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "bd0deaa1113b588d70449535ab9cbf0f2bd0e72f" + "sha": "5747555f7620113d9a2078a48f4c047a99d31b3e" } } ] diff --git a/test/test.externalclient.ts b/test/test.externalclient.ts index 849cc5c8..54f8ded8 100644 --- a/test/test.externalclient.ts +++ b/test/test.externalclient.ts @@ -23,6 +23,7 @@ import {StsSuccessfulResponse} from '../src/auth/stscredentials'; import { EXPIRATION_TIME_OFFSET, ExternalAccountClient, + IamGenerateAccessTokenResponse, } from '../src/auth/externalclient'; import { OAuthErrorResponse, @@ -33,6 +34,14 @@ import {GaxiosError} from 'gaxios'; nock.disableNetConnect(); +interface IamGenerateAccessTokenError { + error: { + code: number; + message: string; + status: string; + }; +} + interface NockMockStsToken { statusCode: number; response: StsSuccessfulResponse | OAuthErrorResponse; @@ -41,6 +50,13 @@ interface NockMockStsToken { additionalHeaders?: {[key: string]: string}; } +interface NockMockGenerateAccessToken { + statusCode: number; + token: string; + response: IamGenerateAccessTokenResponse | IamGenerateAccessTokenError; + scopes: string[]; +} + interface SampleResponse { foo: string; bar: number; @@ -56,12 +72,66 @@ class TestExternalAccountClient extends ExternalAccountClient { } } +const ONE_HOUR_IN_SECS = 3600; +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`; + +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; +} + +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; +} + +function assertGaxiosResponsePresent(resp: GetAccessTokenResponse) { + const gaxiosResponse = resp.res || {}; + assert('data' in gaxiosResponse && 'status' in gaxiosResponse); +} + describe('ExternalAccountClient', () => { let clock: sinon.SinonFakeTimers; - const ONE_HOUR_IN_SECS = 3600; const crypto = createCrypto(); - const baseUrl = 'https://sts.googleapis.com'; - const path = '/v1/token'; const projectNumber = '123456'; const poolId = 'POOL_ID'; const providerId = 'PROVIDER_ID'; @@ -99,29 +169,18 @@ describe('ExternalAccountClient', () => { expires_in: ONE_HOUR_IN_SECS, scope: 'scope1 scope2', }; - - function assertGaxiosResponsePresent(resp: GetAccessTokenResponse) { - const gaxiosResponse = resp.res || {}; - assert('data' in gaxiosResponse && 'status' in gaxiosResponse); - } - - 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; - } + const externalAccountOptionsWithSA = Object.assign( + { + service_account_impersonation_url: `${saBaseUrl}${saPath}`, + }, + externalAccountOptions + ); + const externalAccountOptionsWithCredsAndSA = Object.assign( + { + service_account_impersonation_url: `${saBaseUrl}${saPath}`, + }, + externalAccountOptionsWithCreds + ); afterEach(() => { nock.cleanAll(); @@ -151,349 +210,804 @@ describe('ExternalAccountClient', () => { }); describe('getAccessToken()', () => { - it('should resolve with the expected GetAccessTokenResponse', 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', + 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, + ]); + + 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(); }); - 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', + 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', + { + 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, + ]); + + 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(); }); - 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', + 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, + ]); + + 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(); }); - 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', + 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, + ]); + + 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(); }); - 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', + 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', + { + 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, + ]); + + 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(); }); - // 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, + 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(); }); - // 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, + 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(); }); - - scope.done(); }); - it('should respect eagerRefreshThresholdMillis when provided', 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', - }, + 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', }, - ]); + }; - const client = new TestExternalAccountClient(externalAccountOptions, { - // Override 5min threshold with 10 second threshold. - eagerRefreshThresholdMillis: customThresh, + 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()); }); - const actualResponse = await client.getAccessToken(); - // Confirm raw GaxiosResponse appended to response. - assertGaxiosResponsePresent(actualResponse); - delete actualResponse.res; - assert.deepStrictEqual(actualResponse, { - token: stsSuccessfulResponse.access_token, + 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()); }); - // 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, + 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()); }); - // 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, + 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()); }); - scope.done(); - }); - - it('should apply basic auth when client_id/secret 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 - )}`, - }, - }, - ]); + 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( - externalAccountOptionsWithCreds - ); - const actualResponse = await client.getAccessToken(); + 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()); + }); - // Confirm raw GaxiosResponse appended to response. - assertGaxiosResponsePresent(actualResponse); - delete actualResponse.res; - assert.deepStrictEqual(actualResponse, { - token: stsSuccessfulResponse.access_token, + 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()); }); - scope.done(); }); }); @@ -525,6 +1039,53 @@ describe('ExternalAccountClient', () => { 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 = { @@ -648,6 +1209,77 @@ describe('ExternalAccountClient', () => { 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 = { From bbcd03d5cb43a028b3c2f2248377f7cd8f20f155 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Fri, 21 Aug 2020 11:06:45 -0700 Subject: [PATCH 05/25] feat: defines `IdentityPoolClient` used for K8s and Azure workloads (#1042) --- ...xternalclient.ts => baseexternalclient.ts} | 8 +- src/auth/identitypoolclient.ts | 140 ++++++ test/externalclienthelper.ts | 121 +++++ test/fixtures/external-subject-token.txt | 1 + ...alclient.ts => test.baseexternalclient.ts} | 113 +---- test/test.identitypoolclient.ts | 449 ++++++++++++++++++ 6 files changed, 732 insertions(+), 100 deletions(-) rename src/auth/{externalclient.ts => baseexternalclient.ts} (98%) create mode 100644 src/auth/identitypoolclient.ts create mode 100644 test/externalclienthelper.ts create mode 100644 test/fixtures/external-subject-token.txt rename test/{test.externalclient.ts => test.baseexternalclient.ts} (95%) create mode 100644 test/test.identitypoolclient.ts diff --git a/src/auth/externalclient.ts b/src/auth/baseexternalclient.ts similarity index 98% rename from src/auth/externalclient.ts rename to src/auth/baseexternalclient.ts index 3a1e0690..6de2e6ba 100644 --- a/src/auth/externalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -47,7 +47,7 @@ const EXTERNAL_ACCOUNT_TYPE = 'external_account'; /** * Base external account credentials json interface. */ -export interface ExternalAccountClientOptions { +export interface BaseExternalAccountClientOptions { type: string; audience: string; subject_token_type: string; @@ -86,7 +86,7 @@ interface CredentialsWithResponse extends Credentials { * retrieving the external credential based on the environment and * credential_source will be left for the subclasses. */ -export abstract class ExternalAccountClient extends AuthClient { +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 @@ -102,7 +102,7 @@ export abstract class ExternalAccountClient extends AuthClient { private readonly stsCredential: sts.StsCredentials; /** - * Instantiate an ExternalAccountClient instance using the provided JSON + * 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. @@ -111,7 +111,7 @@ export abstract class ExternalAccountClient extends AuthClient { * whether to retry on 401/403 API request errors. */ constructor( - options: ExternalAccountClientOptions, + options: BaseExternalAccountClientOptions, additionalOptions?: RefreshOptions ) { super(); diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts new file mode 100644 index 00000000..28ae3abf --- /dev/null +++ b/src/auth/identitypoolclient.ts @@ -0,0 +1,140 @@ +// 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 {GaxiosOptions} from 'gaxios'; +import * as fs from 'fs'; +import {promisify} from 'util'; + +import { + BaseExternalAccountClient, + BaseExternalAccountClientOptions, +} from './baseexternalclient'; +import {RefreshOptions} from './oauth2client'; + +const readFile = promisify(fs.readFile); + +/** + * 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; + }; + }; +} + +/** + * 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}; + + /** + * 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'); + } + } + + /** + * 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!); + } else { + return await this.getTokenFromUrl(this.url!, 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. + * @return A promise that resolves with the external subject token. + */ + private getTokenFromFile(filePath: 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 = fs.realpathSync(filePath); + + if (!fs.lstatSync(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; + } + + return readFile(filePath, {encoding: 'utf8'}); + } + + /** + * 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 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, + headers?: {[key: string]: string} + ): Promise { + const opts: GaxiosOptions = { + url, + method: 'GET', + headers, + responseType: 'text', + }; + const response = await this.transporter.request(opts); + return response.data; + } +} diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts new file mode 100644 index 00000000..8bead6c4 --- /dev/null +++ b/test/externalclienthelper.ts @@ -0,0 +1,121 @@ +// 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 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} from '../src/auth/baseexternalclient'; + +interface IamGenerateAccessTokenError { + 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 | IamGenerateAccessTokenError; + scopes: string[]; +} + +const projectNumber = '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(): 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); +} 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.externalclient.ts b/test/test.baseexternalclient.ts similarity index 95% rename from test/test.externalclient.ts rename to test/test.baseexternalclient.ts index 54f8ded8..6d46be35 100644 --- a/test/test.externalclient.ts +++ b/test/test.baseexternalclient.ts @@ -14,7 +14,6 @@ import * as assert from 'assert'; import {describe, it, afterEach} from 'mocha'; -import * as qs from 'querystring'; import * as nock from 'nock'; import * as sinon from 'sinon'; import {createCrypto} from '../src/crypto/crypto'; @@ -22,48 +21,31 @@ import {Credentials} from '../src/auth/credentials'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; import { EXPIRATION_TIME_OFFSET, - ExternalAccountClient, - IamGenerateAccessTokenResponse, -} from '../src/auth/externalclient'; + BaseExternalAccountClient, +} from '../src/auth/baseexternalclient'; import { OAuthErrorResponse, getErrorFromOAuthErrorResponse, } from '../src/auth/oauth2common'; -import {GetAccessTokenResponse} from '../src/auth/oauth2client'; import {GaxiosError} from 'gaxios'; +import { + assertGaxiosResponsePresent, + getAudience, + getTokenUrl, + getServiceAccountImpersonationUrl, + mockGenerateAccessToken, + mockStsTokenExchange, +} from './externalclienthelper'; nock.disableNetConnect(); -interface IamGenerateAccessTokenError { - 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 | IamGenerateAccessTokenError; - scopes: string[]; -} - interface SampleResponse { foo: string; bar: number; } /** Test class to test abstract class ExternalAccountClient. */ -class TestExternalAccountClient extends ExternalAccountClient { +class TestExternalAccountClient extends BaseExternalAccountClient { private counter = 0; async retrieveSubjectToken(): Promise { @@ -73,77 +55,16 @@ class TestExternalAccountClient extends ExternalAccountClient { } const ONE_HOUR_IN_SECS = 3600; -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`; - -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; -} - -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; -} - -function assertGaxiosResponsePresent(resp: GetAccessTokenResponse) { - const gaxiosResponse = resp.res || {}; - assert('data' in gaxiosResponse && 'status' in gaxiosResponse); -} -describe('ExternalAccountClient', () => { +describe('BaseExternalAccountClient', () => { let clock: sinon.SinonFakeTimers; const crypto = createCrypto(); - const projectNumber = '123456'; - const poolId = 'POOL_ID'; - const providerId = 'PROVIDER_ID'; - const audience = - `//iam.googleapis.com/project/${projectNumber}` + - `/locations/global/workloadIdentityPools/${poolId}/` + - `providers/${providerId}`; + const audience = getAudience(); const externalAccountOptions = { type: 'external_account', audience, subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - token_url: `${baseUrl}${path}`, + token_url: getTokenUrl(), credential_source: { file: '/var/run/secrets/goog.id/token', }, @@ -152,7 +73,7 @@ describe('ExternalAccountClient', () => { type: 'external_account', audience, subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - token_url: `${baseUrl}${path}`, + token_url: getTokenUrl(), credential_source: { file: '/var/run/secrets/goog.id/token', }, @@ -171,13 +92,13 @@ describe('ExternalAccountClient', () => { }; const externalAccountOptionsWithSA = Object.assign( { - service_account_impersonation_url: `${saBaseUrl}${saPath}`, + service_account_impersonation_url: getServiceAccountImpersonationUrl(), }, externalAccountOptions ); const externalAccountOptionsWithCredsAndSA = Object.assign( { - service_account_impersonation_url: `${saBaseUrl}${saPath}`, + service_account_impersonation_url: getServiceAccountImpersonationUrl(), }, externalAccountOptionsWithCreds ); diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts new file mode 100644 index 00000000..c74f2d5e --- /dev/null +++ b/test/test.identitypoolclient.ts @@ -0,0 +1,449 @@ +// 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 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 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 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 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 file is found', async () => { + const client = new IdentityPoolClient(fileSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, fileSubjectToken); + }); + + 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', 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', 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 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 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 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', 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', 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 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(); + }); + }); + }); +}); From 26ab5e3430a7002cb46343e2bfbc6595358c7b1f Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Mon, 24 Aug 2020 12:59:08 -0700 Subject: [PATCH 06/25] feat: adds crypto utils for AWS request signing (#1046) --- .../continuous/{node10 => node12}/lint.cfg | 0 .../{node10 => node12}/samples-test.cfg | 0 .../{node10 => node12}/system-test.cfg | 0 .../{node10 => node12}/samples-test.cfg | 0 .../{node10 => node12}/system-test.cfg | 0 README.md | 4 +- browser-test/test.crypto.ts | 66 ++++++++++++++- src/auth/oauth2client.ts | 2 +- src/crypto/browser/crypto.ts | 61 +++++++++++++- src/crypto/crypto.ts | 36 +++++++++ src/crypto/node/crypto.ts | 57 +++++++++++++ synth.metadata | 55 ++++++++++++- test/test.crypto.ts | 80 ++++++++++++++++++- 13 files changed, 353 insertions(+), 8 deletions(-) rename .kokoro/continuous/{node10 => node12}/lint.cfg (100%) rename .kokoro/continuous/{node10 => node12}/samples-test.cfg (100%) rename .kokoro/continuous/{node10 => node12}/system-test.cfg (100%) rename .kokoro/presubmit/{node10 => node12}/samples-test.cfg (100%) rename .kokoro/presubmit/{node10 => node12}/system-test.cfg (100%) diff --git a/.kokoro/continuous/node10/lint.cfg b/.kokoro/continuous/node12/lint.cfg similarity index 100% rename from .kokoro/continuous/node10/lint.cfg rename to .kokoro/continuous/node12/lint.cfg diff --git a/.kokoro/continuous/node10/samples-test.cfg b/.kokoro/continuous/node12/samples-test.cfg similarity index 100% rename from .kokoro/continuous/node10/samples-test.cfg rename to .kokoro/continuous/node12/samples-test.cfg diff --git a/.kokoro/continuous/node10/system-test.cfg b/.kokoro/continuous/node12/system-test.cfg similarity index 100% rename from .kokoro/continuous/node10/system-test.cfg rename to .kokoro/continuous/node12/system-test.cfg diff --git a/.kokoro/presubmit/node10/samples-test.cfg b/.kokoro/presubmit/node12/samples-test.cfg similarity index 100% rename from .kokoro/presubmit/node10/samples-test.cfg rename to .kokoro/presubmit/node12/samples-test.cfg diff --git a/.kokoro/presubmit/node10/system-test.cfg b/.kokoro/presubmit/node12/system-test.cfg similarity index 100% rename from .kokoro/presubmit/node10/system-test.cfg rename to .kokoro/presubmit/node12/system-test.cfg diff --git a/README.md b/README.md index 0c2d03cf..f29c488a 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ const {GoogleAuth} = require('google-auth-library'); async function main() { const url = 'https://cloud-run-1234-uc.a.run.app'; const auth = new GoogleAuth(); - const client = auth.getIdTokenClient(url); + const client = await auth.getIdTokenClient(url); const res = await client.request({url}); console.log(res.data); } @@ -375,7 +375,7 @@ async function main() const targetAudience = 'iap-client-id'; const url = 'https://iap-url.com'; const auth = new GoogleAuth(); - const client = auth.getIdTokenClient(targetAudience); + const client = await auth.getIdTokenClient(targetAudience); const res = await client.request({url}); console.log(res.data); } diff --git a/browser-test/test.crypto.ts b/browser-test/test.crypto.ts index aaa13b3f..c30d04e0 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'; @@ -24,6 +24,21 @@ import {describe, it} from 'mocha'; // text encoding natively. require('fast-text-encoding'); +/** + * Converts a string to an ArrayBuffer. + * https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String + * @param str The string to convert to an ArrayBuffer. + * @return The ArrayBuffer representation of the string. + */ +function stringToArrayBuffer(str: string): ArrayBuffer { + const arrayBuffer = new ArrayBuffer(str.length * 2); + const arrayBufferView = new Uint16Array(arrayBuffer); + for (let i = 0; i < str.length; i++) { + arrayBufferView[i] = str.charCodeAt(i); + } + return arrayBuffer; +} + describe('Browser crypto tests', () => { const crypto = createCrypto(); @@ -99,4 +114,53 @@ 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 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 = stringToArrayBuffer('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 = stringToArrayBuffer('Hello World!'); + const expectedHexEncoding = '48656c6c6f20576f726c6421'; + + const calculatedHexEncoding = fromArrayBufferToHex(arrayBuffer); + assert.strictEqual(calculatedHexEncoding, expectedHexEncoding); + }); }); diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index 9719212c..046863f5 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -545,7 +545,7 @@ export class OAuth2Client extends AuthClient { } /** - * Convenience method to automatically generate a code_verifier, and it's + * Convenience method to automatically generate a code_verifier, and its * resulting SHA256. If used, this must be paired with a S256 * code_challenge_method. * 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..42fe5da3 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 === 0 ? '' : byte.toString(16).padStart(2, '0'); + }) + .join(''); +} diff --git a/src/crypto/node/crypto.ts b/src/crypto/node/crypto.ts index 58b60671..be08a71e 100644 --- a/src/crypto/node/crypto.ts +++ b/src/crypto/node/crypto.ts @@ -49,4 +49,61 @@ 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 { + 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; +} + +/** + * 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 { + const buf = Buffer.alloc(arrayBuffer.byteLength); + const view = new Uint8Array(arrayBuffer); + for (let i = 0; i < buf.length; ++i) { + buf[i] = view[i]; + } + return buf; } diff --git a/synth.metadata b/synth.metadata index 0d36d211..9107ca41 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,15 +4,66 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "4830a5308d780822e884b0fb98fa605d3e7dc77b" + "sha": "0c8e086f3cad23efefb57418f1eeccba6674aaac" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "5747555f7620113d9a2078a48f4c047a99d31b3e" + "sha": "05de3e1e14a0b07eab8b474e669164dbd31f81fb" } } + ], + "generatedFiles": [ + ".eslintignore", + ".eslintrc.json", + ".gitattributes", + ".github/ISSUE_TEMPLATE/bug_report.md", + ".github/ISSUE_TEMPLATE/feature_request.md", + ".github/ISSUE_TEMPLATE/support_request.md", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/publish.yml", + ".github/release-please.yml", + ".github/workflows/ci.yaml", + ".kokoro/.gitattributes", + ".kokoro/common.cfg", + ".kokoro/continuous/node10/common.cfg", + ".kokoro/continuous/node10/docs.cfg", + ".kokoro/continuous/node10/test.cfg", + ".kokoro/continuous/node12/common.cfg", + ".kokoro/continuous/node12/lint.cfg", + ".kokoro/continuous/node12/samples-test.cfg", + ".kokoro/continuous/node12/system-test.cfg", + ".kokoro/continuous/node12/test.cfg", + ".kokoro/docs.sh", + ".kokoro/lint.sh", + ".kokoro/populate-secrets.sh", + ".kokoro/presubmit/node10/common.cfg", + ".kokoro/presubmit/node12/common.cfg", + ".kokoro/presubmit/node12/samples-test.cfg", + ".kokoro/presubmit/node12/system-test.cfg", + ".kokoro/presubmit/node12/test.cfg", + ".kokoro/publish.sh", + ".kokoro/release/docs-devsite.cfg", + ".kokoro/release/docs-devsite.sh", + ".kokoro/release/docs.cfg", + ".kokoro/release/docs.sh", + ".kokoro/release/publish.cfg", + ".kokoro/samples-test.sh", + ".kokoro/system-test.sh", + ".kokoro/test.bat", + ".kokoro/test.sh", + ".kokoro/trampoline.sh", + ".mocharc.js", + ".nycrc", + ".prettierignore", + ".prettierrc.js", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "LICENSE", + "api-extractor.json", + "renovate.json", + "samples/README.md" ] } \ No newline at end of file diff --git a/test/test.crypto.ts b/test/test.crypto.ts index f4faf670..bed2826b 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,53 @@ 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 = toArrayBuffer(Buffer.from('Hello World!')); + const expectedHexEncoding = '48656c6c6f20576f726c6421'; + + const calculatedHexEncoding = fromArrayBufferToHex(arrayBuffer); + assert.strictEqual(calculatedHexEncoding, expectedHexEncoding); + }); }); From 3020c6f9336a4975f016cbf44dd2913e1abbcacf Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 27 Aug 2020 19:41:50 -0700 Subject: [PATCH 07/25] feat: implements AWS signature version 4 for signing requests (#1047) --- browser-test/test.crypto.ts | 5 +- src/auth/awsrequestsigner.ts | 284 ++++++++ src/crypto/crypto.ts | 2 +- test/fixtures/aws-security-credentials.json | 9 + test/test.awsrequestsigner.ts | 742 ++++++++++++++++++++ test/test.crypto.ts | 5 +- 6 files changed, 1042 insertions(+), 5 deletions(-) create mode 100644 src/auth/awsrequestsigner.ts create mode 100644 test/fixtures/aws-security-credentials.json create mode 100644 test/test.awsrequestsigner.ts diff --git a/browser-test/test.crypto.ts b/browser-test/test.crypto.ts index c30d04e0..7f69b542 100644 --- a/browser-test/test.crypto.ts +++ b/browser-test/test.crypto.ts @@ -157,8 +157,9 @@ describe('Browser crypto tests', () => { }); it('should expose a method to convert an ArrayBuffer to hex', () => { - const arrayBuffer = stringToArrayBuffer('Hello World!'); - const expectedHexEncoding = '48656c6c6f20576f726c6421'; + 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/src/auth/awsrequestsigner.ts b/src/auth/awsrequestsigner.ts new file mode 100644 index 00000000..421a4b4b --- /dev/null +++ b/src/auth/awsrequestsigner.ts @@ -0,0 +1,284 @@ +// 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 {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; +} + +/** 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 handler 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( + this.crypto, + uri.host, + uri.pathname, + uri.search.substr(1), + method, + this.region, + 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 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 signature for AWS Signature Version 4. + * Based on: + * https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + * + * @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 accessKeyId The AWS access key ID. + * @param secretAccessKey The AWS secret access kye. + * @param token The AWS token. + * @return The AWS authentication header map which constitutes of the following + * components: amz-date, authorization header and canonical query string. + */ +async function generateAuthenticationHeaderMap( + crypto: Crypto, + host: string, + canonicalUri: string, + canonicalQuerystring: string, + method: HttpMethod, + region: string, + securityCredentials: AwsSecurityCredentials, + requestPayload = '', + additionalAmzHeaders: Headers = {} +): Promise { + // iam.amazonaws.com host => iam service. + // sts.us-east-2.amazonaws.com => sts service. + const serviceName = 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 (securityCredentials.token) { + reformattedAdditionalAmzHeaders['x-amz-security-token'] = + securityCredentials.token; + } + // Header keys need to be sorted alphabetically. + const amzHeaders = Object.assign( + { + 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 crypto.sha256DigestHex(requestPayload); + // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + const canonicalRequest = + `${method}\n` + + `${canonicalUri}\n` + + `${canonicalQuerystring}\n` + + `${canonicalHeaders}\n` + + `${signedHeaders}\n` + + `${payloadHash}`; + const credentialScope = `${dateStamp}/${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 crypto.sha256DigestHex(canonicalRequest)); + // https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + const signingKey = await getSigningKey( + crypto, + securityCredentials.secretAccessKey, + dateStamp, + region, + serviceName + ); + const signature = await sign(crypto, signingKey, stringToSign); + // https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html + const authorizationHeader = + `${AWS_ALGORITHM} Credential=${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, + }; +} diff --git a/src/crypto/crypto.ts b/src/crypto/crypto.ts index 42fe5da3..be50295e 100644 --- a/src/crypto/crypto.ts +++ b/src/crypto/crypto.ts @@ -99,7 +99,7 @@ export function fromArrayBufferToHex(arrayBuffer: ArrayBuffer): string { // Convert bytes to hex string. return byteArray .map(byte => { - return byte === 0 ? '' : byte.toString(16).padStart(2, '0'); + return byte.toString(16).padStart(2, '0'); }) .join(''); } diff --git a/test/fixtures/aws-security-credentials.json b/test/fixtures/aws-security-credentials.json new file mode 100644 index 00000000..76e7688a --- /dev/null +++ b/test/fixtures/aws-security-credentials.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" +} \ No newline at end of file diff --git a/test/test.awsrequestsigner.ts b/test/test.awsrequestsigner.ts new file mode 100644 index 00000000..e37ae282 --- /dev/null +++ b/test/test.awsrequestsigner.ts @@ -0,0 +1,742 @@ +// 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 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.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.crypto.ts b/test/test.crypto.ts index bed2826b..101d94c8 100644 --- a/test/test.crypto.ts +++ b/test/test.crypto.ts @@ -152,8 +152,9 @@ describe('crypto', () => { }); it('should expose a method to convert an ArrayBuffer to hex', () => { - const arrayBuffer = toArrayBuffer(Buffer.from('Hello World!')); - const expectedHexEncoding = '48656c6c6f20576f726c6421'; + const arrayBuffer = new Uint8Array([4, 8, 0, 12, 16, 0]) + .buffer as ArrayBuffer; + const expectedHexEncoding = '0408000c1000'; const calculatedHexEncoding = fromArrayBufferToHex(arrayBuffer); assert.strictEqual(calculatedHexEncoding, expectedHexEncoding); From b7075e9a8c07597e5d8837fa082e8b18621d8e26 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Fri, 28 Aug 2020 17:52:59 -0700 Subject: [PATCH 08/25] feat: defines `AwsClient` used for AWS workloads (#1049) --- .github/publish.yml | 0 .kokoro/release/publish.cfg | 2 +- .kokoro/samples-test.sh | 2 +- .kokoro/system-test.sh | 2 +- .kokoro/test.sh | 2 +- browser-test/test.crypto.ts | 19 +- src/auth/awsclient.ts | 249 +++++++ src/auth/baseexternalclient.ts | 2 +- synth.metadata | 5 +- ...son => aws-security-credentials-fake.json} | 0 test/test.awsclient.ts | 646 ++++++++++++++++++ test/test.awsrequestsigner.ts | 2 +- 12 files changed, 906 insertions(+), 25 deletions(-) delete mode 100644 .github/publish.yml create mode 100644 src/auth/awsclient.ts rename test/fixtures/{aws-security-credentials.json => aws-security-credentials-fake.json} (100%) create mode 100644 test/test.awsclient.ts diff --git a/.github/publish.yml b/.github/publish.yml deleted file mode 100644 index e69de29b..00000000 diff --git a/.kokoro/release/publish.cfg b/.kokoro/release/publish.cfg index e63ee55f..a70341d4 100644 --- a/.kokoro/release/publish.cfg +++ b/.kokoro/release/publish.cfg @@ -49,7 +49,7 @@ before_action { env_vars: { key: "SECRET_MANAGER_KEYS" - value: "npm_publish_token" + value: "npm_publish_token,releasetool-publish-reporter-app,releasetool-publish-reporter-googleapis-installation,releasetool-publish-reporter-pem" } # Download trampoline resources. diff --git a/.kokoro/samples-test.sh b/.kokoro/samples-test.sh index 86e83c9d..c0c40139 100755 --- a/.kokoro/samples-test.sh +++ b/.kokoro/samples-test.sh @@ -41,7 +41,7 @@ if [ -f samples/package.json ]; then cd .. # If tests are running against master, configure Build Cop # to open issues on failures: - if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]]; then + if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]] || [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"nightly"* ]]; then export MOCHA_REPORTER_OUTPUT=test_output_sponge_log.xml export MOCHA_REPORTER=xunit cleanup() { diff --git a/.kokoro/system-test.sh b/.kokoro/system-test.sh index dfae142a..283f1700 100755 --- a/.kokoro/system-test.sh +++ b/.kokoro/system-test.sh @@ -35,7 +35,7 @@ npm install # If tests are running against master, configure Build Cop # to open issues on failures: -if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]]; then +if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]] || [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"nightly"* ]]; then export MOCHA_REPORTER_OUTPUT=test_output_sponge_log.xml export MOCHA_REPORTER=xunit cleanup() { diff --git a/.kokoro/test.sh b/.kokoro/test.sh index 8d9c2954..47be59b9 100755 --- a/.kokoro/test.sh +++ b/.kokoro/test.sh @@ -23,7 +23,7 @@ cd $(dirname $0)/.. npm install # If tests are running against master, configure Build Cop # to open issues on failures: -if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]]; then +if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]] || [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"nightly"* ]]; then export MOCHA_REPORTER_OUTPUT=test_output_sponge_log.xml export MOCHA_REPORTER=xunit cleanup() { diff --git a/browser-test/test.crypto.ts b/browser-test/test.crypto.ts index 7f69b542..8028ecfa 100644 --- a/browser-test/test.crypto.ts +++ b/browser-test/test.crypto.ts @@ -24,21 +24,6 @@ import {describe, it} from 'mocha'; // text encoding natively. require('fast-text-encoding'); -/** - * Converts a string to an ArrayBuffer. - * https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String - * @param str The string to convert to an ArrayBuffer. - * @return The ArrayBuffer representation of the string. - */ -function stringToArrayBuffer(str: string): ArrayBuffer { - const arrayBuffer = new ArrayBuffer(str.length * 2); - const arrayBufferView = new Uint16Array(arrayBuffer); - for (let i = 0; i < str.length; i++) { - arrayBufferView[i] = str.charCodeAt(i); - } - return arrayBuffer; -} - describe('Browser crypto tests', () => { const crypto = createCrypto(); @@ -142,7 +127,9 @@ describe('Browser crypto tests', () => { it('using an ArrayBuffer key', async () => { const message = 'The quick brown fox jumps over the lazy dog'; - const key = stringToArrayBuffer('key'); + // String "key" ArrayBuffer representation. + const key = new Uint8Array([107, 0, 101, 0, 121, 0]) + .buffer as ArrayBuffer; const expectedHexHash = 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; const extectedHash = new Uint8Array( diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts new file mode 100644 index 00000000..ff567732 --- /dev/null +++ b/src/auth/awsclient.ts @@ -0,0 +1,249 @@ +// 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 {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_url: string; + 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.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 envIdComponents = this.environmentId?.match(/^(aws)([\d]+)$/) || []; + const envId = envIdComponents[1]; + const envVersion = envIdComponents[2]; + if ( + envId !== 'aws' || + !this.regionUrl || + !this.regionalCredVerificationUrl + ) { + throw new Error('No valid AWS "credential_source" provided'); + } else if (parseInt(envVersion, 10) !== 1) { + throw new Error( + `aws version "${envVersion}" 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 { + 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/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 6de2e6ba..5a1237be 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -96,7 +96,7 @@ export abstract class BaseExternalAccountClient extends AuthClient { private cachedAccessToken: CredentialsWithResponse | null; private eagerRefreshThresholdMillis: number; private forceRefreshOnFailure: boolean; - private readonly audience: string; + protected readonly audience: string; private readonly subjectTokenType: string; private readonly serviceAccountImpersonationUrl?: string; private readonly stsCredential: sts.StsCredentials; diff --git a/synth.metadata b/synth.metadata index 9107ca41..f3345524 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "0c8e086f3cad23efefb57418f1eeccba6674aaac" + "sha": "5164845bbeb46733a56e0d2c25d6c205e671fe3c" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "05de3e1e14a0b07eab8b474e669164dbd31f81fb" + "sha": "8cf6d2834ad14318e64429c3b94f6443ae83daf9" } } ], @@ -23,7 +23,6 @@ ".github/ISSUE_TEMPLATE/feature_request.md", ".github/ISSUE_TEMPLATE/support_request.md", ".github/PULL_REQUEST_TEMPLATE.md", - ".github/publish.yml", ".github/release-please.yml", ".github/workflows/ci.yaml", ".kokoro/.gitattributes", diff --git a/test/fixtures/aws-security-credentials.json b/test/fixtures/aws-security-credentials-fake.json similarity index 100% rename from test/fixtures/aws-security-credentials.json rename to test/fixtures/aws-security-credentials-fake.json diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts new file mode 100644 index 00000000..3a89d8eb --- /dev/null +++ b/test/test.awsclient.ts @@ -0,0 +1,646 @@ +// 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 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', + 'region_url', + '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(); + }); + }); + + 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; + + beforeEach(() => { + // Store external state. + envAwsAccessKeyId = process.env.AWS_ACCESS_KEY_ID; + envAwsSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + envAwsSessionToken = process.env.AWS_SESSION_TOKEN; + // Reset environment variables. + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; + }); + + 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; + } + }); + + 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(); + }); + }); + + 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 index e37ae282..fbc1eada 100644 --- a/test/test.awsrequestsigner.ts +++ b/test/test.awsrequestsigner.ts @@ -36,7 +36,7 @@ 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.json'); + const awsSecurityCredentials = require('../../test/fixtures/aws-security-credentials-fake.json'); const accessKeyId = awsSecurityCredentials.AccessKeyId; const secretAccessKey = awsSecurityCredentials.SecretAccessKey; const token = awsSecurityCredentials.Token; From 150740e710b885501c58eea90039bb6dde1f1217 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 1 Sep 2020 17:48:40 -0700 Subject: [PATCH 09/25] feat: defines `ExternalAccountClient` used to instantiate external account clients (#1050) --- src/auth/baseexternalclient.ts | 2 +- src/auth/externalclient.ts | 74 ++++++++++++++++ test/test.externalclient.ts | 154 +++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/auth/externalclient.ts create mode 100644 test/test.externalclient.ts diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 5a1237be..db65a90d 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -42,7 +42,7 @@ const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; */ export const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000; /** The credentials JSON file type for external account clients. */ -const EXTERNAL_ACCOUNT_TYPE = 'external_account'; +export const EXTERNAL_ACCOUNT_TYPE = 'external_account'; /** * Base external account credentials json interface. diff --git a/src/auth/externalclient.ts b/src/auth/externalclient.ts new file mode 100644 index 00000000..f3c43966 --- /dev/null +++ b/src/auth/externalclient.ts @@ -0,0 +1,74 @@ +// 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 {RefreshOptions} from './oauth2client'; +import { + BaseExternalAccountClient, + BaseExternalAccountClientOptions, + 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: BaseExternalAccountClientOptions, + 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/test/test.externalclient.ts b/test/test.externalclient.ts new file mode 100644 index 00000000..fda3ec56 --- /dev/null +++ b/test/test.externalclient.ts @@ -0,0 +1,154 @@ +// 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 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); + }); + }); + }); +}); From d759b0914ef65167640a34310b04b8d66f4577b2 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 2 Sep 2020 11:42:47 -0700 Subject: [PATCH 10/25] feat: implements `BaseExternalAccountClient.getProjectId()` (#1051) --- src/auth/baseexternalclient.ts | 73 ++++++++++++++++ test/externalclienthelper.ts | 30 +++++-- test/test.baseexternalclient.ts | 145 ++++++++++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 5 deletions(-) diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index db65a90d..75ab05c1 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -43,6 +43,9 @@ const DEFAULT_OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; export const EXPIRATION_TIME_OFFSET = 5 * 60 * 1000; /** The credentials JSON file type for external account clients. */ 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. @@ -70,6 +73,21 @@ export interface IamGenerateAccessTokenResponse { 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. */ @@ -100,6 +118,8 @@ export abstract class BaseExternalAccountClient extends AuthClient { private readonly subjectTokenType: string; private readonly serviceAccountImpersonationUrl?: string; private readonly stsCredential: sts.StsCredentials; + public projectId: string | null; + public projectNumber: string | null; /** * Instantiate a BaseExternalAccountClient instance using the provided JSON @@ -147,6 +167,8 @@ export abstract class BaseExternalAccountClient extends AuthClient { .eagerRefreshThresholdMillis as number; } this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; + this.projectId = null; + this.projectNumber = this.getProjectNumber(this.audience); } /** @@ -228,6 +250,38 @@ export abstract class BaseExternalAccountClient extends AuthClient { } } + /** + * @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. @@ -342,6 +396,25 @@ export abstract class BaseExternalAccountClient extends AuthClient { 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 components = audience.split('/'); + const projectIndex = components.indexOf('projects'); + if (projectIndex !== -1 && projectIndex !== components.length - 1) { + return components[projectIndex + 1] || null; + } + return null; + } + /** * Exchanges an external account GCP access token for a service * account impersonated access token using iamcredentials diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts index 8bead6c4..314fb1a8 100644 --- a/test/externalclienthelper.ts +++ b/test/externalclienthelper.ts @@ -18,9 +18,12 @@ 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} from '../src/auth/baseexternalclient'; +import { + IamGenerateAccessTokenResponse, + ProjectInfo, +} from '../src/auth/baseexternalclient'; -interface IamGenerateAccessTokenError { +interface CloudRequestError { error: { code: number; message: string; @@ -39,11 +42,11 @@ interface NockMockStsToken { interface NockMockGenerateAccessToken { statusCode: number; token: string; - response: IamGenerateAccessTokenResponse | IamGenerateAccessTokenError; + response: IamGenerateAccessTokenResponse | CloudRequestError; scopes: string[]; } -const projectNumber = '123456'; +const defaultProjectNumber = '123456'; const poolId = 'POOL_ID'; const providerId = 'PROVIDER_ID'; const baseUrl = 'https://sts.googleapis.com'; @@ -99,7 +102,9 @@ export function mockGenerateAccessToken( return scope; } -export function getAudience(): string { +export function getAudience( + projectNumber: string = defaultProjectNumber +): string { return ( `//iam.googleapis.com/projects/${projectNumber}` + `/locations/global/workloadIdentityPools/${poolId}/` + @@ -119,3 +124,18 @@ 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/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 6d46be35..beb43cdd 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -33,6 +33,7 @@ import { getAudience, getTokenUrl, getServiceAccountImpersonationUrl, + mockCloudResourceManager, mockGenerateAccessToken, mockStsTokenExchange, } from './externalclienthelper'; @@ -102,6 +103,17 @@ describe('BaseExternalAccountClient', () => { }, 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(); @@ -130,6 +142,139 @@ describe('BaseExternalAccountClient', () => { }); }); + 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 () => { From 291652a244b68ac8397b0102eb9b0721f682f2ae Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 8 Sep 2020 17:03:43 -0700 Subject: [PATCH 11/25] feat!: integrates external_accounts with `GoogleAuth` and ADC (#1052) --- src/auth/authclient.ts | 90 +++- src/auth/baseexternalclient.ts | 22 +- src/auth/externalclient.ts | 10 +- src/auth/googleauth.ts | 117 +++-- src/index.ts | 9 + test/fixtures/external-account-cred.json | 9 + test/test.baseexternalclient.ts | 27 ++ test/test.googleauth.ts | 531 ++++++++++++++++++++++- test/test.index.ts | 3 + 9 files changed, 767 insertions(+), 51 deletions(-) create mode 100644 test/fixtures/external-account-cred.json diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index f7d4d13c..9f85faec 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/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 75ab05c1..f30edd53 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -41,7 +41,13 @@ 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. */ +/** + * 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 = @@ -112,14 +118,14 @@ export abstract class BaseExternalAccountClient extends AuthClient { */ public scopes?: string | string[]; private cachedAccessToken: CredentialsWithResponse | null; - private eagerRefreshThresholdMillis: number; - private forceRefreshOnFailure: boolean; 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 @@ -467,9 +473,15 @@ export abstract class BaseExternalAccountClient extends AuthClient { /** * @return The list of scopes for the requested GCP access token. */ - private getScopesArray(): string[] | undefined { + private getScopesArray(): string[] { // Since scopes can be provided as string or array, the type should // be normalized. - return typeof this.scopes === 'string' ? [this.scopes] : this.scopes; + 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 index f3c43966..67d02a57 100644 --- a/src/auth/externalclient.ts +++ b/src/auth/externalclient.ts @@ -15,7 +15,13 @@ import {RefreshOptions} from './oauth2client'; import { BaseExternalAccountClient, - BaseExternalAccountClientOptions, + // 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 { @@ -55,7 +61,7 @@ export class ExternalAccountClient { * provided do not correspond to an external account credential. */ static fromJSON( - options: BaseExternalAccountClientOptions, + options: ExternalAccountClientOptions, additionalOptions?: RefreshOptions ): BaseExternalAccountClient | null { if (options && options.type === EXTERNAL_ACCOUNT_TYPE) { diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index fb5065e0..e5724487 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; private keyFilename?: string; private scopes?: string | string[]; @@ -174,7 +181,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( @@ -229,12 +237,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 @@ -243,7 +251,10 @@ export class GoogleAuth { options ); if (credential) { - if (credential instanceof JWT) { + if ( + credential instanceof JWT || + credential instanceof BaseExternalAccountClient + ) { credential.scopes = this.scopes; } this.cachedCredential = credential; @@ -256,7 +267,10 @@ export class GoogleAuth { options ); if (credential) { - if (credential instanceof JWT) { + if ( + credential instanceof JWT || + credential instanceof BaseExternalAccountClient + ) { credential.scopes = this.scopes; } this.cachedCredential = credential; @@ -307,7 +321,7 @@ export class GoogleAuth { */ async _tryGetApplicationCredentialsFromEnvironmentVariable( options?: RefreshOptions - ): Promise { + ): Promise { const credentialsPath = process.env['GOOGLE_APPLICATION_CREDENTIALS'] || process.env['google_application_credentials']; @@ -332,7 +346,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()) { @@ -377,7 +391,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.'); @@ -409,8 +423,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.' @@ -419,11 +433,18 @@ 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.scopes; } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); + client.fromJSON(json); } - client.fromJSON(json); return client; } @@ -437,17 +458,24 @@ 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.scopes; } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); + client.fromJSON(json); } - client.fromJSON(json); // cache both raw data used to instantiate client and client itself. this.jsonContent = json; this.cachedCredential = client; @@ -459,12 +487,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, @@ -474,7 +502,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; @@ -494,7 +522,7 @@ export class GoogleAuth { private fromStreamAsync( inputStream: stream.Readable, options?: RefreshOptions - ): Promise { + ): Promise { return new Promise((resolve, reject) => { if (!inputStream) { throw new Error( @@ -607,6 +635,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) { + const creds = await this.getClient(); + try { + return await (creds as BaseExternalAccountClient).getProjectId(); + } catch (e) { + return null; + } + } + return null; + } + /** * Gets the Compute Engine project ID if it can be inferred. */ @@ -649,8 +692,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/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/fixtures/external-account-cred.json b/test/fixtures/external-account-cred.json new file mode 100644 index 00000000..df7ba5a6 --- /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" + } +} \ No newline at end of file diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index beb43cdd..e9975c7c 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -140,6 +140,33 @@ describe('BaseExternalAccountClient', () => { 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', () => { diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 109998de..621a3718 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -29,10 +29,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(); @@ -58,6 +73,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:', @@ -159,10 +176,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() { @@ -961,7 +979,7 @@ describe('googleauth', () => { // a JWTClient. assert.strictEqual( 'compute-placeholder', - res.credential.credentials.refresh_token + (res.credential as OAuth2Client).credentials.refresh_token ); }); @@ -1523,5 +1541,508 @@ 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, + }; + + /** + * @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. + * @return The list of nock.Scope corresponding to the mocked HTTP + * requests. + */ + function mockGetAccessTokenAndProjectId( + mockProjectIdRetrieval = true + ): nock.Scope[] { + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience: externalAccountJSON.audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + 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 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 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()); + }); + }); + + 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 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()); + }); + }); }); }); 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); }); }); From b8f14ec8d0d22ba037c2214017f1ae6e1fabeaea Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 24 Sep 2020 10:56:51 -0700 Subject: [PATCH 12/25] chore: sync to master (#1058) --- .github/workflows/ci.yaml | 2 +- .mocharc.js | 3 +- CHANGELOG.md | 7 ++ package.json | 2 +- samples/package.json | 2 +- src/auth/googleauth.ts | 38 +++++++--- src/auth/jwtaccess.ts | 41 ++++++++-- src/auth/jwtclient.ts | 38 +++++++--- synth.metadata | 4 +- test/test.googleauth.ts | 154 +++++++++++++++++++++++++++++++++++++- test/test.jwt.ts | 110 +++++++++++++++++++++++++++ test/test.transporters.ts | 2 - 12 files changed, 363 insertions(+), 40 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5e73bb3d..7dd110e3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: with: node-version: ${{ matrix.node }} - run: node --version - - run: npm install + - run: npm install --engine-strict - run: npm test - name: coverage uses: codecov/codecov-action@v1 diff --git a/.mocharc.js b/.mocharc.js index ff7b34fa..0b600509 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -14,7 +14,8 @@ const config = { "enable-source-maps": true, "throw-deprecation": true, - "timeout": 10000 + "timeout": 10000, + "recursive": true } if (process.env.MOCHA_THROW_DEPRECATION === 'false') { delete config['throw-deprecation']; diff --git a/CHANGELOG.md b/CHANGELOG.md index daba9d57..2f40cf64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions +## [6.1.0](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.8...v6.1.0) (2020-09-22) + + +### Features + +* default self-signed JWTs ([#1054](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1054)) ([b4d139d](https://www.github.com/googleapis/google-auth-library-nodejs/commit/b4d139d9ee27f886ca8cc5478615c052700fff48)) + ### [6.0.8](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.7...v6.0.8) (2020-08-13) diff --git a/package.json b/package.json index f5f04166..276ffab1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "6.0.8", + "version": "6.1.0", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { diff --git a/samples/package.json b/samples/package.json index 57e6e542..e4fe63fc 100644 --- a/samples/package.json +++ b/samples/package.json @@ -12,7 +12,7 @@ }, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^6.0.8", + "google-auth-library": "^6.1.0", "node-fetch": "^2.3.0", "opn": "^5.3.0", "server-destroy": "^1.0.1" diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index e5724487..d4982b22 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -126,6 +126,12 @@ export class GoogleAuth { cachedCredential: JSONClient | Compute | null = null; + /** + * Scopes populated by the client library by default. We differentiate between + * these and user defined scopes when deciding whether to use a self-signed JWT. + */ + defaultScopes?: string | string[]; + private keyFilename?: string; private scopes?: string | string[]; private clientOptions?: RefreshOptions; @@ -201,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. @@ -251,11 +265,11 @@ export class GoogleAuth { options ); if (credential) { - if ( - credential instanceof JWT || - credential instanceof BaseExternalAccountClient - ) { + 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(); @@ -267,11 +281,11 @@ export class GoogleAuth { options ); if (credential) { - if ( - credential instanceof JWT || - credential instanceof BaseExternalAccountClient - ) { + 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(); @@ -296,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; + (options as ComputeOptions).scopes = this.getAnyScopes(); this.cachedCredential = new Compute(options); projectId = await this.getProjectId(); return {projectId, credential: this.cachedCredential}; @@ -439,10 +453,11 @@ export class GoogleAuth { json as ExternalAccountClientOptions, options )!; - client.scopes = this.scopes; + client.scopes = this.getAnyScopes(); } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); + client.defaultScopes = this.defaultScopes; client.fromJSON(json); } return client; @@ -470,10 +485,11 @@ export class GoogleAuth { json as ExternalAccountClientOptions, options )!; - client.scopes = this.scopes; + client.scopes = this.getAnyScopes(); } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); + client.defaultScopes = this.defaultScopes; client.fromJSON(json); } // cache both raw data used to instantiate client and client itself. diff --git a/src/auth/jwtaccess.ts b/src/auth/jwtaccess.ts index 0139683e..30298b6c 100644 --- a/src/auth/jwtaccess.ts +++ b/src/auth/jwtaccess.ts @@ -33,8 +33,12 @@ export class JWTAccess { key?: string | null; keyId?: string | null; projectId?: string; + eagerRefreshThresholdMillis: number; - private cache = new LRU({max: 500, maxAge: 60 * 60 * 1000}); + private cache = new LRU({ + max: 500, + maxAge: 60 * 60 * 1000, + }); /** * JWTAccess service account credentials. @@ -49,11 +53,14 @@ export class JWTAccess { constructor( email?: string | null, key?: string | null, - keyId?: string | null + keyId?: string | null, + eagerRefreshThresholdMillis?: number ) { this.email = email; this.key = key; this.keyId = keyId; + this.eagerRefreshThresholdMillis = + eagerRefreshThresholdMillis ?? 5 * 60 * 1000; } /** @@ -65,12 +72,18 @@ export class JWTAccess { * @returns An object that includes the authorization header. */ getRequestHeaders(url: string, additionalClaims?: Claims): Headers { + // Return cached authorization headers, unless we are within + // eagerRefreshThresholdMillis ms of them expiring: const cachedToken = this.cache.get(url); - if (cachedToken) { - return cachedToken; + const now = Date.now(); + if ( + cachedToken && + cachedToken.expiration - now > this.eagerRefreshThresholdMillis + ) { + return cachedToken.headers; } - const iat = Math.floor(new Date().getTime() / 1000); - const exp = iat + 3600; // 3600 seconds = 1 hour + const iat = Math.floor(Date.now() / 1000); + const exp = JWTAccess.getExpirationTime(iat); // The payload used for signed JWT headers has: // iss == sub == @@ -103,10 +116,24 @@ export class JWTAccess { // Sign the jwt and add it to the cache const signedJWT = jws.sign({header, payload, secret: this.key}); const headers = {Authorization: `Bearer ${signedJWT}`}; - this.cache.set(url, headers); + this.cache.set(url, { + expiration: exp * 1000, + headers, + }); return headers; } + /** + * Returns an expiration time for the JWT token. + * + * @param iat The issued at time for the JWT. + * @returns An expiration time for the JWT. + */ + private static getExpirationTime(iat: number): number { + const exp = iat + 3600; // 3600 seconds = 1 hour + return exp; + } + /** * Create a JWTAccess credentials instance using the given input options. * @param json The input object. diff --git a/src/auth/jwtclient.ts b/src/auth/jwtclient.ts index c203cbb5..344aff62 100644 --- a/src/auth/jwtclient.ts +++ b/src/auth/jwtclient.ts @@ -40,6 +40,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { keyFile?: string; key?: string; keyId?: string; + defaultScopes?: string | string[]; scopes?: string | string[]; scope?: string; subject?: string; @@ -120,7 +121,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { protected async getRequestMetadataAsync( url?: string | null ): Promise { - if (!this.apiKey && !this.hasScopes() && url) { + if (!this.apiKey && !this.hasUserScopes() && url) { if ( this.additionalClaims && (this.additionalClaims as { @@ -137,7 +138,12 @@ export class JWT extends OAuth2Client implements IdTokenProvider { // no scopes have been set, but a uri has been provided. Use JWTAccess // credentials. if (!this.access) { - this.access = new JWTAccess(this.email, this.key, this.keyId); + this.access = new JWTAccess( + this.email, + this.key, + this.keyId, + this.eagerRefreshThresholdMillis + ); } const headers = await this.access.getRequestHeaders( url, @@ -145,8 +151,12 @@ export class JWT extends OAuth2Client implements IdTokenProvider { ); return {headers: this.addSharedMetadataHeaders(headers)}; } - } else { + } else if (this.hasAnyScopes() || this.apiKey) { return super.getRequestMetadataAsync(url); + } else { + // If no audience, apiKey, or scopes are provided, we should not attempt + // to populate any headers: + return {headers: {}}; } } @@ -159,7 +169,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { const gtoken = new GoogleToken({ iss: this.email, sub: this.subject, - scope: this.scopes, + scope: this.scopes || this.defaultScopes, keyFile: this.keyFile, key: this.key, additionalClaims: {target_audience: targetAudience}, @@ -176,16 +186,20 @@ export class JWT extends OAuth2Client implements IdTokenProvider { /** * Determine if there are currently scopes available. */ - private hasScopes() { + private hasUserScopes() { if (!this.scopes) { return false; } - // For arrays, check the array length. - if (this.scopes instanceof Array) { - return this.scopes.length > 0; - } - // For others, convert to a string and check the length. - return String(this.scopes).length > 0; + return this.scopes.length > 0; + } + + /** + * Are there any default or user scopes defined. + */ + private hasAnyScopes() { + if (this.scopes && this.scopes.length > 0) return true; + if (this.defaultScopes && this.defaultScopes.length > 0) return true; + return false; } /** @@ -248,7 +262,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { this.gtoken = new GoogleToken({ iss: this.email, sub: this.subject, - scope: this.scopes, + scope: this.scopes || this.defaultScopes, keyFile: this.keyFile, key: this.key, additionalClaims: this.additionalClaims, diff --git a/synth.metadata b/synth.metadata index f3345524..1da5b692 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "5164845bbeb46733a56e0d2c25d6c205e671fe3c" + "sha": "55b98171efffc2e7dfecd46ff3485382c3ab62f5" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "8cf6d2834ad14318e64429c3b94f6443ae83daf9" + "sha": "fdd03c161003ab97657cc0218f25c82c89ddf4b6" } } ], diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 621a3718..c7517d36 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -1576,6 +1576,8 @@ describe('googleauth', () => { 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. @@ -1595,11 +1597,13 @@ describe('googleauth', () => { * 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 + mockProjectIdRetrieval = true, + expectedScopes = ['https://www.googleapis.com/auth/cloud-platform'] ): nock.Scope[] { const scopes = [ mockStsTokenExchange([ @@ -1609,7 +1613,7 @@ describe('googleauth', () => { request: { grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', audience: externalAccountJSON.audience, - scope: 'https://www.googleapis.com/auth/cloud-platform', + scope: expectedScopes.join(' '), requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', subject_token: fileSubjectToken, @@ -1671,6 +1675,31 @@ describe('googleauth', () => { 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); @@ -1741,6 +1770,58 @@ describe('googleauth', () => { 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. @@ -1759,6 +1840,54 @@ describe('googleauth', () => { 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()', () => { @@ -1949,6 +2078,27 @@ describe('googleauth', () => { 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'; diff --git a/test/test.jwt.ts b/test/test.jwt.ts index 14b40477..dd569a03 100644 --- a/test/test.jwt.ts +++ b/test/test.jwt.ts @@ -21,6 +21,7 @@ import * as sinon from 'sinon'; import {GoogleAuth, JWT} from '../src'; import {CredentialRequest, JWTInput} from '../src/auth/credentials'; +import * as jwtaccess from '../src/auth/jwtaccess'; describe('jwt', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -783,4 +784,113 @@ describe('jwt', () => { } assert.fail('failed to throw'); }); + + describe('self-signed JWT', () => { + afterEach(() => { + sandbox.restore(); + }); + + it('uses self signed JWT when no scopes are provided', async () => { + const stubJWTAccess = sandbox.stub(jwtaccess, 'JWTAccess').returns({ + getRequestHeaders: sinon.stub().returns({}), + }); + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: fs.readFileSync(PEM_PATH, 'utf8'), + scopes: [], + subject: 'bar@subjectaccount.com', + }); + jwt.credentials = {refresh_token: 'jwt-placeholder'}; + await jwt.getRequestHeaders('https//beepboop.googleapis.com'); + sandbox.assert.calledOnce(stubJWTAccess); + }); + + it('uses self signed JWT when default scopes are provided', async () => { + const JWTAccess = sandbox.stub(jwtaccess, 'JWTAccess').returns({ + getRequestHeaders: sinon.stub().returns({}), + }); + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: fs.readFileSync(PEM_PATH, 'utf8'), + subject: 'bar@subjectaccount.com', + }); + jwt.defaultScopes = ['http://bar', 'http://foo']; + jwt.credentials = {refresh_token: 'jwt-placeholder'}; + await jwt.getRequestHeaders('https//beepboop.googleapis.com'); + sandbox.assert.calledOnce(JWTAccess); + }); + + it('does not use self signed JWT if target_audience provided', async () => { + const JWTAccess = sandbox.stub(jwtaccess, 'JWTAccess').returns({ + getRequestHeaders: sinon.stub().returns({}), + }); + const keys = keypair(512 /* bitsize of private key */); + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: keys.private, + subject: 'ignored@subjectaccount.com', + additionalClaims: {target_audience: 'beepboop'}, + }); + jwt.defaultScopes = ['foo', 'bar']; + jwt.credentials = {refresh_token: 'jwt-placeholder'}; + const testUri = 'http:/example.com/my_test_service'; + const scope = createGTokenMock({id_token: 'abc123'}); + await jwt.getRequestHeaders(testUri); + scope.done(); + sandbox.assert.notCalled(JWTAccess); + }); + + it('returns headers from cache, prior to their expiry time', async () => { + const sign = sandbox.stub(jws, 'sign').returns('abc123'); + const getExpirationTime = sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(jwtaccess.JWTAccess as any, 'getExpirationTime') + .returns(Date.now() / 1000 + 3600); // expire in an hour. + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: fs.readFileSync(PEM_PATH, 'utf8'), + scopes: [], + subject: 'bar@subjectaccount.com', + }); + jwt.credentials = {refresh_token: 'jwt-placeholder'}; + await jwt.getRequestHeaders('https//beepboop.googleapis.com'); + // The second time we fetch headers should not cause getExpirationTime + // to be invoked a second time: + await jwt.getRequestHeaders('https//beepboop.googleapis.com'); + sandbox.assert.calledOnce(getExpirationTime); + sandbox.assert.calledOnce(sign); + }); + + it('creates a new self-signed JWT, if headers are close to expiring', async () => { + const sign = sandbox.stub(jws, 'sign').returns('abc123'); + const getExpirationTime = sandbox + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(jwtaccess.JWTAccess as any, 'getExpirationTime') + .returns(Date.now() / 1000 + 5); // expire in 5 seconds. + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: fs.readFileSync(PEM_PATH, 'utf8'), + scopes: [], + subject: 'bar@subjectaccount.com', + }); + jwt.credentials = {refresh_token: 'jwt-placeholder'}; + await jwt.getRequestHeaders('https//beepboop.googleapis.com'); + // The second time we fetch headers should not cause getExpirationTime + // to be invoked a second time: + await jwt.getRequestHeaders('https//beepboop.googleapis.com'); + sandbox.assert.calledTwice(getExpirationTime); + sandbox.assert.calledTwice(sign); + }); + + it('returns no headers when no scopes or audiences are provided', async () => { + const jwt = new JWT({ + email: 'foo@serviceaccount.com', + key: fs.readFileSync(PEM_PATH, 'utf8'), + scopes: [], + subject: 'bar@subjectaccount.com', + }); + const headers = await jwt.getRequestHeaders(); + assert.deepStrictEqual(headers, {}); + }); + }); }); diff --git a/test/test.transporters.ts b/test/test.transporters.ts index 5861dd3d..08410db4 100644 --- a/test/test.transporters.ts +++ b/test/test.transporters.ts @@ -82,9 +82,7 @@ describe('transporters', () => { url: '', }; let configuredOpts = transporter.configure(opts); - console.info(configuredOpts); configuredOpts = transporter.configure(opts); - console.info(configuredOpts); assert( /^gdcl\/[.-\w$]+ auth\/[.-\w$]+$/.test( configuredOpts.headers!['x-goog-api-client'] From 317b1d2db20cee1885ec4705a91dc4194f1d4f87 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Mon, 28 Sep 2020 15:49:24 -0700 Subject: [PATCH 13/25] feat: adds text/json credential_source support to IdentityPoolClients (#1059) --- src/auth/identitypoolclient.ts | 88 +++++- test/fixtures/external-subject-token.json | 3 + test/test.identitypoolclient.ts | 350 +++++++++++++++++++++- 3 files changed, 428 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/external-subject-token.json diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 28ae3abf..eef78622 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -24,6 +24,12 @@ import {RefreshOptions} from './oauth2client'; const readFile = promisify(fs.readFile); +type SubjectTokenFormatType = 'json' | 'text'; + +interface SubjectTokenJsonResponse { + [key: string]: string; +} + /** * Url-sourced/file-sourced credentials json interface. * This is used for K8s and Azure workloads. @@ -36,6 +42,10 @@ export interface IdentityPoolClientOptions headers?: { [key: string]: string; }; + format?: { + type: SubjectTokenFormatType; + subject_token_field_name?: string; + }; }; } @@ -47,6 +57,8 @@ 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 @@ -70,6 +82,18 @@ export class IdentityPoolClient extends BaseExternalAccountClient { 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' + ); + } } /** @@ -84,9 +108,18 @@ export class IdentityPoolClient extends BaseExternalAccountClient { */ async retrieveSubjectToken(): Promise { if (this.file) { - return await this.getTokenFromFile(this.file!); + return await this.getTokenFromFile( + this.file!, + this.formatType, + this.formatSubjectTokenFieldName + ); } else { - return await this.getTokenFromUrl(this.url!, this.headers); + return await this.getTokenFromUrl( + this.url!, + this.formatType, + this.formatSubjectTokenFieldName, + this.headers + ); } } @@ -94,9 +127,17 @@ export class IdentityPoolClient extends BaseExternalAccountClient { * 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 getTokenFromFile(filePath: string): Promise { + 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 { @@ -112,7 +153,20 @@ export class IdentityPoolClient extends BaseExternalAccountClient { throw err; } - return readFile(filePath, {encoding: 'utf8'}); + 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; } /** @@ -120,21 +174,41 @@ export class IdentityPoolClient extends BaseExternalAccountClient { * 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: 'text', + responseType: formatType, }; - const response = await this.transporter.request(opts); - return response.data; + 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/test/fixtures/external-subject-token.json b/test/fixtures/external-subject-token.json new file mode 100644 index 00000000..a47ec341 --- /dev/null +++ b/test/fixtures/external-subject-token.json @@ -0,0 +1,3 @@ +{ + "access_token": "HEADER.SIMULATED_JWT_PAYLOAD.SIGNATURE" +} \ No newline at end of file diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts index c74f2d5e..446b792d 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -63,6 +63,25 @@ describe('IdentityPoolClient', () => { }, 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, @@ -95,6 +114,26 @@ describe('IdentityPoolClient', () => { }, 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', @@ -128,6 +167,50 @@ describe('IdentityPoolClient', () => { }, 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); @@ -153,13 +236,42 @@ describe('IdentityPoolClient', () => { describe('for file-sourced subject tokens', () => { describe('retrieveSubjectToken()', () => { - it('should resolve when the file is found', async () => { + 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); @@ -195,7 +307,7 @@ describe('IdentityPoolClient', () => { }); describe('getAccessToken()', () => { - it('should resolve on retrieveSubjectToken success', async () => { + it('should resolve on retrieveSubjectToken success for text format', async () => { const scope = mockStsTokenExchange([ { statusCode: 200, @@ -225,7 +337,7 @@ describe('IdentityPoolClient', () => { scope.done(); }); - it('should handle service account access token', async () => { + it('should handle service account access token for text format', async () => { const now = new Date().getTime(); const saSuccessResponse = { accessToken: 'SA_ACCESS_TOKEN', @@ -271,6 +383,82 @@ describe('IdentityPoolClient', () => { 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); @@ -287,7 +475,7 @@ describe('IdentityPoolClient', () => { describe('for url-sourced subject tokens', () => { describe('retrieveSubjectToken()', () => { - it('should resolve on success', async () => { + it('should resolve on text response success', async () => { const externalSubjectToken = 'SUBJECT_TOKEN_1'; const scope = nock(metadataBaseUrl) .get(metadataPath, undefined, { @@ -302,6 +490,57 @@ describe('IdentityPoolClient', () => { 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); @@ -337,7 +576,7 @@ describe('IdentityPoolClient', () => { }); describe('getAccessToken()', () => { - it('should resolve on retrieveSubjectToken success', async () => { + it('should resolve on retrieveSubjectToken success for text format', async () => { const externalSubjectToken = 'SUBJECT_TOKEN_1'; const scopes: nock.Scope[] = []; scopes.push( @@ -378,7 +617,7 @@ describe('IdentityPoolClient', () => { scopes.forEach(scope => scope.done()); }); - it('should handle service account access token', async () => { + it('should handle service account access token for text format', async () => { const now = new Date().getTime(); const saSuccessResponse = { accessToken: 'SA_ACCESS_TOKEN', @@ -430,6 +669,105 @@ describe('IdentityPoolClient', () => { 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, { From 8f657ce1a592d9fd3a0d7bab4bb044f56d62f8bf Mon Sep 17 00:00:00 2001 From: Wilfred van der Deijl Date: Thu, 8 Oct 2020 03:08:51 +0200 Subject: [PATCH 14/25] feat: get AWS region from environment variable (#1067) --- src/auth/awsclient.ts | 3 +++ test/test.awsclient.ts | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index ff567732..825d3408 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -199,6 +199,9 @@ export class AwsClient extends BaseExternalAccountClient { * @return A promise that resolves with the current AWS region. */ private async getAwsRegion(): Promise { + if (process.env['AWS_REGION']) { + return process.env['AWS_REGION']; + } const opts: GaxiosOptions = { url: this.regionUrl, method: 'GET', diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index 3a89d8eb..4793f742 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -504,16 +504,19 @@ describe('AwsClient', () => { 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(() => { @@ -533,6 +536,11 @@ describe('AwsClient', () => { } else { delete process.env.AWS_SESSION_TOKEN; } + if (envAwsRegion) { + process.env.AWS_REGION = envAwsRegion; + } else { + delete process.env.AWS_REGION; + } }); describe('retrieveSubjectToken()', () => { @@ -583,6 +591,17 @@ describe('AwsClient', () => { }); 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); + }); }); describe('getAccessToken()', () => { From ec84e81a61d7d42a61c16534a46dd81c17b6ab3c Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 21 Oct 2020 08:58:36 -0700 Subject: [PATCH 15/25] fix: makes AWS fields optional when determinable from envvars (#1081) --- .github/workflows/ci.yaml | 6 +- .kokoro/common.cfg | 2 +- .kokoro/continuous/node10/common.cfg | 2 +- .kokoro/continuous/node12/common.cfg | 2 +- .kokoro/docs.sh | 2 +- .kokoro/lint.sh | 2 +- .kokoro/populate-secrets.sh | 65 ++- .kokoro/presubmit/node10/common.cfg | 2 +- .kokoro/presubmit/node12/common.cfg | 2 +- .kokoro/publish.sh | 2 +- .kokoro/release/docs-devsite.cfg | 2 +- .kokoro/release/docs-devsite.sh | 6 +- .kokoro/release/docs.cfg | 2 +- .kokoro/release/docs.sh | 4 +- .kokoro/release/publish.cfg | 6 +- .kokoro/samples-test.sh | 2 +- .kokoro/system-test.sh | 2 +- .kokoro/test.sh | 2 +- .kokoro/trampoline.sh | 4 + .kokoro/trampoline_v2.sh | 488 ++++++++++++++++++++++ .trampolinerc | 51 +++ CHANGELOG.md | 14 + package.json | 8 +- samples/package.json | 2 +- src/auth/awsclient.ts | 27 +- src/auth/awsrequestsigner.ts | 101 +++-- synth.metadata | 18 +- system-test/fixtures/kitchen/package.json | 2 +- test/test.awsclient.ts | 49 ++- 29 files changed, 771 insertions(+), 106 deletions(-) create mode 100755 .kokoro/trampoline_v2.sh create mode 100644 .trampolinerc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7dd110e3..06067a8c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,11 @@ jobs: with: node-version: ${{ matrix.node }} - run: node --version - - run: npm install --engine-strict + # The first installation step ensures that all of our production + # dependencies work on the given Node.js version, this helps us find + # dependencies that don't match our engines field: + - run: npm install --production --engine-strict + - run: npm install - run: npm test - name: coverage uses: codecov/codecov-action@v1 diff --git a/.kokoro/common.cfg b/.kokoro/common.cfg index e3c7faf0..03e6b50b 100644 --- a/.kokoro/common.cfg +++ b/.kokoro/common.cfg @@ -11,7 +11,7 @@ action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { diff --git a/.kokoro/continuous/node10/common.cfg b/.kokoro/continuous/node10/common.cfg index 0792fe1c..d4231f90 100644 --- a/.kokoro/continuous/node10/common.cfg +++ b/.kokoro/continuous/node10/common.cfg @@ -21,7 +21,7 @@ before_action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { diff --git a/.kokoro/continuous/node12/common.cfg b/.kokoro/continuous/node12/common.cfg index f2825615..8e9e508e 100644 --- a/.kokoro/continuous/node12/common.cfg +++ b/.kokoro/continuous/node12/common.cfg @@ -11,7 +11,7 @@ action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { diff --git a/.kokoro/docs.sh b/.kokoro/docs.sh index 952403fa..85901242 100755 --- a/.kokoro/docs.sh +++ b/.kokoro/docs.sh @@ -16,7 +16,7 @@ set -eo pipefail -export NPM_CONFIG_PREFIX=/home/node/.npm-global +export NPM_CONFIG_PREFIX=${HOME}/.npm-global cd $(dirname $0)/.. diff --git a/.kokoro/lint.sh b/.kokoro/lint.sh index b03cb043..aef4866e 100755 --- a/.kokoro/lint.sh +++ b/.kokoro/lint.sh @@ -16,7 +16,7 @@ set -eo pipefail -export NPM_CONFIG_PREFIX=/home/node/.npm-global +export NPM_CONFIG_PREFIX=${HOME}/.npm-global cd $(dirname $0)/.. diff --git a/.kokoro/populate-secrets.sh b/.kokoro/populate-secrets.sh index 6f9d2288..deb2b199 100755 --- a/.kokoro/populate-secrets.sh +++ b/.kokoro/populate-secrets.sh @@ -13,31 +13,64 @@ # See the License for the specific language governing permissions and # limitations under the License. +# This file is called in the early stage of `trampoline_v2.sh` to +# populate secrets needed for the CI builds. + set -eo pipefail function now { date +"%Y-%m-%d %H:%M:%S" | tr -d '\n' ;} function msg { println "$*" >&2 ;} function println { printf '%s\n' "$(now) $*" ;} +# Populates requested secrets set in SECRET_MANAGER_KEYS + +# In Kokoro CI builds, we use the service account attached to the +# Kokoro VM. This means we need to setup auth on other CI systems. +# For local run, we just use the gcloud command for retrieving the +# secrets. + +if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + GCLOUD_COMMANDS=( + "docker" + "run" + "--entrypoint=gcloud" + "--volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR}" + "gcr.io/google.com/cloudsdktool/cloud-sdk" + ) + if [[ "${TRAMPOLINE_CI:-}" == "kokoro" ]]; then + SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" + else + echo "Authentication for this CI system is not implemented yet." + exit 2 + # TODO: Determine appropriate SECRET_LOCATION and the GCLOUD_COMMANDS. + fi +else + # For local run, use /dev/shm or temporary directory for + # KOKORO_GFILE_DIR. + if [[ -d "/dev/shm" ]]; then + export KOKORO_GFILE_DIR=/dev/shm + else + export KOKORO_GFILE_DIR=$(mktemp -d -t ci-XXXXXXXX) + fi + SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" + GCLOUD_COMMANDS=("gcloud") +fi -# Populates requested secrets set in SECRET_MANAGER_KEYS from service account: -# kokoro-trampoline@cloud-devrel-kokoro-resources.iam.gserviceaccount.com -SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" msg "Creating folder on disk for secrets: ${SECRET_LOCATION}" mkdir -p ${SECRET_LOCATION} + for key in $(echo ${SECRET_MANAGER_KEYS} | sed "s/,/ /g") do - msg "Retrieving secret ${key}" - docker run --entrypoint=gcloud \ - --volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR} \ - gcr.io/google.com/cloudsdktool/cloud-sdk \ - secrets versions access latest \ - --project cloud-devrel-kokoro-resources \ - --secret $key > \ - "$SECRET_LOCATION/$key" - if [[ $? == 0 ]]; then - msg "Secret written to ${SECRET_LOCATION}/${key}" - else - msg "Error retrieving secret ${key}" - fi + msg "Retrieving secret ${key}" + "${GCLOUD_COMMANDS[@]}" \ + secrets versions access latest \ + --project cloud-devrel-kokoro-resources \ + --secret $key > \ + "$SECRET_LOCATION/$key" + if [[ $? == 0 ]]; then + msg "Secret written to ${SECRET_LOCATION}/${key}" + else + msg "Error retrieving secret ${key}" + exit 2 + fi done diff --git a/.kokoro/presubmit/node10/common.cfg b/.kokoro/presubmit/node10/common.cfg index 0792fe1c..d4231f90 100644 --- a/.kokoro/presubmit/node10/common.cfg +++ b/.kokoro/presubmit/node10/common.cfg @@ -21,7 +21,7 @@ before_action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { diff --git a/.kokoro/presubmit/node12/common.cfg b/.kokoro/presubmit/node12/common.cfg index f2825615..8e9e508e 100644 --- a/.kokoro/presubmit/node12/common.cfg +++ b/.kokoro/presubmit/node12/common.cfg @@ -11,7 +11,7 @@ action { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { diff --git a/.kokoro/publish.sh b/.kokoro/publish.sh index f056d861..4db6bf1c 100755 --- a/.kokoro/publish.sh +++ b/.kokoro/publish.sh @@ -16,7 +16,7 @@ set -eo pipefail -export NPM_CONFIG_PREFIX=/home/node/.npm-global +export NPM_CONFIG_PREFIX=${HOME}/.npm-global # Start the releasetool reporter python3 -m pip install gcp-releasetool diff --git a/.kokoro/release/docs-devsite.cfg b/.kokoro/release/docs-devsite.cfg index 906330c2..c37e25cd 100644 --- a/.kokoro/release/docs-devsite.cfg +++ b/.kokoro/release/docs-devsite.cfg @@ -18,7 +18,7 @@ env_vars: { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" env_vars: { key: "TRAMPOLINE_BUILD_FILE" diff --git a/.kokoro/release/docs-devsite.sh b/.kokoro/release/docs-devsite.sh index fa089cf2..0d11b7ae 100755 --- a/.kokoro/release/docs-devsite.sh +++ b/.kokoro/release/docs-devsite.sh @@ -20,8 +20,8 @@ set -eo pipefail if [[ -z "$CREDENTIALS" ]]; then # if CREDENTIALS are explicitly set, assume we're testing locally # and don't set NPM_CONFIG_PREFIX. - export NPM_CONFIG_PREFIX=/home/node/.npm-global - export PATH="$PATH:/home/node/.npm-global/bin" + export NPM_CONFIG_PREFIX=${HOME}/.npm-global + export PATH="$PATH:${NPM_CONFIG_PREFIX}/bin" cd $(dirname $0)/../.. fi @@ -61,7 +61,7 @@ if [[ -z "$CREDENTIALS" ]]; then CREDENTIALS=${KOKORO_KEYSTORE_DIR}/73713_docuploader_service_account fi if [[ -z "$BUCKET" ]]; then - BUCKET=docs-staging-v2-staging + BUCKET=docs-staging-v2 fi python3 -m docuploader upload ./_devsite --destination-prefix docfx --credentials $CREDENTIALS --staging-bucket $BUCKET diff --git a/.kokoro/release/docs.cfg b/.kokoro/release/docs.cfg index 344676ea..8f8a1316 100644 --- a/.kokoro/release/docs.cfg +++ b/.kokoro/release/docs.cfg @@ -18,7 +18,7 @@ env_vars: { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" env_vars: { key: "TRAMPOLINE_BUILD_FILE" diff --git a/.kokoro/release/docs.sh b/.kokoro/release/docs.sh index 4d3a0868..4c866c86 100755 --- a/.kokoro/release/docs.sh +++ b/.kokoro/release/docs.sh @@ -20,8 +20,8 @@ set -eo pipefail if [[ -z "$CREDENTIALS" ]]; then # if CREDENTIALS are explicitly set, assume we're testing locally # and don't set NPM_CONFIG_PREFIX. - export NPM_CONFIG_PREFIX=/home/node/.npm-global - export PATH="$PATH:/home/node/.npm-global/bin" + export NPM_CONFIG_PREFIX=${HOME}/.npm-global + export PATH="$PATH:${NPM_CONFIG_PREFIX}/bin" cd $(dirname $0)/../.. fi npm install diff --git a/.kokoro/release/publish.cfg b/.kokoro/release/publish.cfg index a70341d4..15dee6a1 100644 --- a/.kokoro/release/publish.cfg +++ b/.kokoro/release/publish.cfg @@ -27,7 +27,7 @@ before_action { } } -# Fetch magictoken to use with Magic Github Proxy +# Fetch magictoken to use with Magic Github Proxy before_action { fetch_keystore { keystore_resource { @@ -37,7 +37,7 @@ before_action { } } -# Fetch api key to use with Magic Github Proxy +# Fetch api key to use with Magic Github Proxy before_action { fetch_keystore { keystore_resource { @@ -56,7 +56,7 @@ env_vars: { gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" # Use the trampoline script to run in docker. -build_file: "google-auth-library-nodejs/.kokoro/trampoline.sh" +build_file: "google-auth-library-nodejs/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { diff --git a/.kokoro/samples-test.sh b/.kokoro/samples-test.sh index c0c40139..bab7ba4e 100755 --- a/.kokoro/samples-test.sh +++ b/.kokoro/samples-test.sh @@ -16,7 +16,7 @@ set -eo pipefail -export NPM_CONFIG_PREFIX=/home/node/.npm-global +export NPM_CONFIG_PREFIX=${HOME}/.npm-global # Setup service account credentials. export GOOGLE_APPLICATION_CREDENTIALS=${KOKORO_GFILE_DIR}/service-account.json diff --git a/.kokoro/system-test.sh b/.kokoro/system-test.sh index 283f1700..8a084004 100755 --- a/.kokoro/system-test.sh +++ b/.kokoro/system-test.sh @@ -16,7 +16,7 @@ set -eo pipefail -export NPM_CONFIG_PREFIX=/home/node/.npm-global +export NPM_CONFIG_PREFIX=${HOME}/.npm-global # Setup service account credentials. export GOOGLE_APPLICATION_CREDENTIALS=${KOKORO_GFILE_DIR}/service-account.json diff --git a/.kokoro/test.sh b/.kokoro/test.sh index 47be59b9..5be385fe 100755 --- a/.kokoro/test.sh +++ b/.kokoro/test.sh @@ -16,7 +16,7 @@ set -eo pipefail -export NPM_CONFIG_PREFIX=/home/node/.npm-global +export NPM_CONFIG_PREFIX=${HOME}/.npm-global cd $(dirname $0)/.. diff --git a/.kokoro/trampoline.sh b/.kokoro/trampoline.sh index a4241db2..f693a1ce 100755 --- a/.kokoro/trampoline.sh +++ b/.kokoro/trampoline.sh @@ -13,6 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +# This file is not used any more, but we keep this file for making it +# easy to roll back. +# TODO: Remove this file from the template. + set -eo pipefail # Always run the cleanup script, regardless of the success of bouncing into diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh new file mode 100755 index 00000000..5ae75f97 --- /dev/null +++ b/.kokoro/trampoline_v2.sh @@ -0,0 +1,488 @@ +#!/usr/bin/env bash +# 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. + +# trampoline_v2.sh +# +# If you want to make a change to this file, consider doing so at: +# https://github.com/googlecloudplatform/docker-ci-helper +# +# This script is for running CI builds. For Kokoro builds, we +# set this script to `build_file` field in the Kokoro configuration. + +# This script does 3 things. +# +# 1. Prepare the Docker image for the test +# 2. Run the Docker with appropriate flags to run the test +# 3. Upload the newly built Docker image +# +# in a way that is somewhat compatible with trampoline_v1. +# +# These environment variables are required: +# TRAMPOLINE_IMAGE: The docker image to use. +# TRAMPOLINE_DOCKERFILE: The location of the Dockerfile. +# +# You can optionally change these environment variables: +# TRAMPOLINE_IMAGE_UPLOAD: +# (true|false): Whether to upload the Docker image after the +# successful builds. +# TRAMPOLINE_BUILD_FILE: The script to run in the docker container. +# TRAMPOLINE_WORKSPACE: The workspace path in the docker container. +# Defaults to /workspace. +# Potentially there are some repo specific envvars in .trampolinerc in +# the project root. +# +# Here is an example for running this script. +# TRAMPOLINE_IMAGE=gcr.io/cloud-devrel-kokoro-resources/node:10-user \ +# TRAMPOLINE_BUILD_FILE=.kokoro/system-test.sh \ +# .kokoro/trampoline_v2.sh + +set -euo pipefail + +TRAMPOLINE_VERSION="2.0.7" + +if command -v tput >/dev/null && [[ -n "${TERM:-}" ]]; then + readonly IO_COLOR_RED="$(tput setaf 1)" + readonly IO_COLOR_GREEN="$(tput setaf 2)" + readonly IO_COLOR_YELLOW="$(tput setaf 3)" + readonly IO_COLOR_RESET="$(tput sgr0)" +else + readonly IO_COLOR_RED="" + readonly IO_COLOR_GREEN="" + readonly IO_COLOR_YELLOW="" + readonly IO_COLOR_RESET="" +fi + +function function_exists { + [ $(LC_ALL=C type -t $1)"" == "function" ] +} + +# Logs a message using the given color. The first argument must be one +# of the IO_COLOR_* variables defined above, such as +# "${IO_COLOR_YELLOW}". The remaining arguments will be logged in the +# given color. The log message will also have an RFC-3339 timestamp +# prepended (in UTC). You can disable the color output by setting +# TERM=vt100. +function log_impl() { + local color="$1" + shift + local timestamp="$(date -u "+%Y-%m-%dT%H:%M:%SZ")" + echo "================================================================" + echo "${color}${timestamp}:" "$@" "${IO_COLOR_RESET}" + echo "================================================================" +} + +# Logs the given message with normal coloring and a timestamp. +function log() { + log_impl "${IO_COLOR_RESET}" "$@" +} + +# Logs the given message in green with a timestamp. +function log_green() { + log_impl "${IO_COLOR_GREEN}" "$@" +} + +# Logs the given message in yellow with a timestamp. +function log_yellow() { + log_impl "${IO_COLOR_YELLOW}" "$@" +} + +# Logs the given message in red with a timestamp. +function log_red() { + log_impl "${IO_COLOR_RED}" "$@" +} + +readonly tmpdir=$(mktemp -d -t ci-XXXXXXXX) +readonly tmphome="${tmpdir}/h" +mkdir -p "${tmphome}" + +function cleanup() { + rm -rf "${tmpdir}" +} +trap cleanup EXIT + +RUNNING_IN_CI="${RUNNING_IN_CI:-false}" + +# The workspace in the container, defaults to /workspace. +TRAMPOLINE_WORKSPACE="${TRAMPOLINE_WORKSPACE:-/workspace}" + +pass_down_envvars=( + # TRAMPOLINE_V2 variables. + # Tells scripts whether they are running as part of CI or not. + "RUNNING_IN_CI" + # Indicates which CI system we're in. + "TRAMPOLINE_CI" + # Indicates the version of the script. + "TRAMPOLINE_VERSION" +) + +log_yellow "Building with Trampoline ${TRAMPOLINE_VERSION}" + +# Detect which CI systems we're in. If we're in any of the CI systems +# we support, `RUNNING_IN_CI` will be true and `TRAMPOLINE_CI` will be +# the name of the CI system. Both envvars will be passing down to the +# container for telling which CI system we're in. +if [[ -n "${KOKORO_BUILD_ID:-}" ]]; then + # descriptive env var for indicating it's on CI. + RUNNING_IN_CI="true" + TRAMPOLINE_CI="kokoro" + if [[ "${TRAMPOLINE_USE_LEGACY_SERVICE_ACCOUNT:-}" == "true" ]]; then + if [[ ! -f "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" ]]; then + log_red "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json does not exist. Did you forget to mount cloud-devrel-kokoro-resources/trampoline? Aborting." + exit 1 + fi + # This service account will be activated later. + TRAMPOLINE_SERVICE_ACCOUNT="${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" + else + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + gcloud auth list + fi + log_yellow "Configuring Container Registry access" + gcloud auth configure-docker --quiet + fi + pass_down_envvars+=( + # KOKORO dynamic variables. + "KOKORO_BUILD_NUMBER" + "KOKORO_BUILD_ID" + "KOKORO_JOB_NAME" + "KOKORO_GIT_COMMIT" + "KOKORO_GITHUB_COMMIT" + "KOKORO_GITHUB_PULL_REQUEST_NUMBER" + "KOKORO_GITHUB_PULL_REQUEST_COMMIT" + # For Build Cop Bot + "KOKORO_GITHUB_COMMIT_URL" + "KOKORO_GITHUB_PULL_REQUEST_URL" + ) +elif [[ "${TRAVIS:-}" == "true" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="travis" + pass_down_envvars+=( + "TRAVIS_BRANCH" + "TRAVIS_BUILD_ID" + "TRAVIS_BUILD_NUMBER" + "TRAVIS_BUILD_WEB_URL" + "TRAVIS_COMMIT" + "TRAVIS_COMMIT_MESSAGE" + "TRAVIS_COMMIT_RANGE" + "TRAVIS_JOB_NAME" + "TRAVIS_JOB_NUMBER" + "TRAVIS_JOB_WEB_URL" + "TRAVIS_PULL_REQUEST" + "TRAVIS_PULL_REQUEST_BRANCH" + "TRAVIS_PULL_REQUEST_SHA" + "TRAVIS_PULL_REQUEST_SLUG" + "TRAVIS_REPO_SLUG" + "TRAVIS_SECURE_ENV_VARS" + "TRAVIS_TAG" + ) +elif [[ -n "${GITHUB_RUN_ID:-}" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="github-workflow" + pass_down_envvars+=( + "GITHUB_WORKFLOW" + "GITHUB_RUN_ID" + "GITHUB_RUN_NUMBER" + "GITHUB_ACTION" + "GITHUB_ACTIONS" + "GITHUB_ACTOR" + "GITHUB_REPOSITORY" + "GITHUB_EVENT_NAME" + "GITHUB_EVENT_PATH" + "GITHUB_SHA" + "GITHUB_REF" + "GITHUB_HEAD_REF" + "GITHUB_BASE_REF" + ) +elif [[ "${CIRCLECI:-}" == "true" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="circleci" + pass_down_envvars+=( + "CIRCLE_BRANCH" + "CIRCLE_BUILD_NUM" + "CIRCLE_BUILD_URL" + "CIRCLE_COMPARE_URL" + "CIRCLE_JOB" + "CIRCLE_NODE_INDEX" + "CIRCLE_NODE_TOTAL" + "CIRCLE_PREVIOUS_BUILD_NUM" + "CIRCLE_PROJECT_REPONAME" + "CIRCLE_PROJECT_USERNAME" + "CIRCLE_REPOSITORY_URL" + "CIRCLE_SHA1" + "CIRCLE_STAGE" + "CIRCLE_USERNAME" + "CIRCLE_WORKFLOW_ID" + "CIRCLE_WORKFLOW_JOB_ID" + "CIRCLE_WORKFLOW_UPSTREAM_JOB_IDS" + "CIRCLE_WORKFLOW_WORKSPACE_ID" + ) +fi + +# Configure the service account for pulling the docker image. +function repo_root() { + local dir="$1" + while [[ ! -d "${dir}/.git" ]]; do + dir="$(dirname "$dir")" + done + echo "${dir}" +} + +# Detect the project root. In CI builds, we assume the script is in +# the git tree and traverse from there, otherwise, traverse from `pwd` +# to find `.git` directory. +if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + PROGRAM_PATH="$(realpath "$0")" + PROGRAM_DIR="$(dirname "${PROGRAM_PATH}")" + PROJECT_ROOT="$(repo_root "${PROGRAM_DIR}")" +else + PROJECT_ROOT="$(repo_root $(pwd))" +fi + +log_yellow "Changing to the project root: ${PROJECT_ROOT}." +cd "${PROJECT_ROOT}" + +# To support relative path for `TRAMPOLINE_SERVICE_ACCOUNT`, we need +# to use this environment variable in `PROJECT_ROOT`. +if [[ -n "${TRAMPOLINE_SERVICE_ACCOUNT:-}" ]]; then + + mkdir -p "${tmpdir}/gcloud" + gcloud_config_dir="${tmpdir}/gcloud" + + log_yellow "Using isolated gcloud config: ${gcloud_config_dir}." + export CLOUDSDK_CONFIG="${gcloud_config_dir}" + + log_yellow "Using ${TRAMPOLINE_SERVICE_ACCOUNT} for authentication." + gcloud auth activate-service-account \ + --key-file "${TRAMPOLINE_SERVICE_ACCOUNT}" + log_yellow "Configuring Container Registry access" + gcloud auth configure-docker --quiet +fi + +required_envvars=( + # The basic trampoline configurations. + "TRAMPOLINE_IMAGE" + "TRAMPOLINE_BUILD_FILE" +) + +if [[ -f "${PROJECT_ROOT}/.trampolinerc" ]]; then + source "${PROJECT_ROOT}/.trampolinerc" +fi + +log_yellow "Checking environment variables." +for e in "${required_envvars[@]}" +do + if [[ -z "${!e:-}" ]]; then + log "Missing ${e} env var. Aborting." + exit 1 + fi +done + +# We want to support legacy style TRAMPOLINE_BUILD_FILE used with V1 +# script: e.g. "github/repo-name/.kokoro/run_tests.sh" +TRAMPOLINE_BUILD_FILE="${TRAMPOLINE_BUILD_FILE#github/*/}" +log_yellow "Using TRAMPOLINE_BUILD_FILE: ${TRAMPOLINE_BUILD_FILE}" + +# ignore error on docker operations and test execution +set +e + +log_yellow "Preparing Docker image." +# We only download the docker image in CI builds. +if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + # Download the docker image specified by `TRAMPOLINE_IMAGE` + + # We may want to add --max-concurrent-downloads flag. + + log_yellow "Start pulling the Docker image: ${TRAMPOLINE_IMAGE}." + if docker pull "${TRAMPOLINE_IMAGE}"; then + log_green "Finished pulling the Docker image: ${TRAMPOLINE_IMAGE}." + has_image="true" + else + log_red "Failed pulling the Docker image: ${TRAMPOLINE_IMAGE}." + has_image="false" + fi +else + # For local run, check if we have the image. + if docker images "${TRAMPOLINE_IMAGE}" | grep "${TRAMPOLINE_IMAGE%:*}"; then + has_image="true" + else + has_image="false" + fi +fi + + +# The default user for a Docker container has uid 0 (root). To avoid +# creating root-owned files in the build directory we tell docker to +# use the current user ID. +user_uid="$(id -u)" +user_gid="$(id -g)" +user_name="$(id -un)" + +# To allow docker in docker, we add the user to the docker group in +# the host os. +docker_gid=$(cut -d: -f3 < <(getent group docker)) + +update_cache="false" +if [[ "${TRAMPOLINE_DOCKERFILE:-none}" != "none" ]]; then + # Build the Docker image from the source. + context_dir=$(dirname "${TRAMPOLINE_DOCKERFILE}") + docker_build_flags=( + "-f" "${TRAMPOLINE_DOCKERFILE}" + "-t" "${TRAMPOLINE_IMAGE}" + "--build-arg" "UID=${user_uid}" + "--build-arg" "USERNAME=${user_name}" + ) + if [[ "${has_image}" == "true" ]]; then + docker_build_flags+=("--cache-from" "${TRAMPOLINE_IMAGE}") + fi + + log_yellow "Start building the docker image." + if [[ "${TRAMPOLINE_VERBOSE:-false}" == "true" ]]; then + echo "docker build" "${docker_build_flags[@]}" "${context_dir}" + fi + + # ON CI systems, we want to suppress docker build logs, only + # output the logs when it fails. + if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + if docker build "${docker_build_flags[@]}" "${context_dir}" \ + > "${tmpdir}/docker_build.log" 2>&1; then + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + cat "${tmpdir}/docker_build.log" + fi + + log_green "Finished building the docker image." + update_cache="true" + else + log_red "Failed to build the Docker image, aborting." + log_yellow "Dumping the build logs:" + cat "${tmpdir}/docker_build.log" + exit 1 + fi + else + if docker build "${docker_build_flags[@]}" "${context_dir}"; then + log_green "Finished building the docker image." + update_cache="true" + else + log_red "Failed to build the Docker image, aborting." + exit 1 + fi + fi +else + if [[ "${has_image}" != "true" ]]; then + log_red "We do not have ${TRAMPOLINE_IMAGE} locally, aborting." + exit 1 + fi +fi + +# We use an array for the flags so they are easier to document. +docker_flags=( + # Remove the container after it exists. + "--rm" + + # Use the host network. + "--network=host" + + # Run in priviledged mode. We are not using docker for sandboxing or + # isolation, just for packaging our dev tools. + "--privileged" + + # Run the docker script with the user id. Because the docker image gets to + # write in ${PWD} you typically want this to be your user id. + # To allow docker in docker, we need to use docker gid on the host. + "--user" "${user_uid}:${docker_gid}" + + # Pass down the USER. + "--env" "USER=${user_name}" + + # Mount the project directory inside the Docker container. + "--volume" "${PROJECT_ROOT}:${TRAMPOLINE_WORKSPACE}" + "--workdir" "${TRAMPOLINE_WORKSPACE}" + "--env" "PROJECT_ROOT=${TRAMPOLINE_WORKSPACE}" + + # Mount the temporary home directory. + "--volume" "${tmphome}:/h" + "--env" "HOME=/h" + + # Allow docker in docker. + "--volume" "/var/run/docker.sock:/var/run/docker.sock" + + # Mount the /tmp so that docker in docker can mount the files + # there correctly. + "--volume" "/tmp:/tmp" + # Pass down the KOKORO_GFILE_DIR and KOKORO_KEYSTORE_DIR + # TODO(tmatsuo): This part is not portable. + "--env" "TRAMPOLINE_SECRET_DIR=/secrets" + "--volume" "${KOKORO_GFILE_DIR:-/dev/shm}:/secrets/gfile" + "--env" "KOKORO_GFILE_DIR=/secrets/gfile" + "--volume" "${KOKORO_KEYSTORE_DIR:-/dev/shm}:/secrets/keystore" + "--env" "KOKORO_KEYSTORE_DIR=/secrets/keystore" +) + +# Add an option for nicer output if the build gets a tty. +if [[ -t 0 ]]; then + docker_flags+=("-it") +fi + +# Passing down env vars +for e in "${pass_down_envvars[@]}" +do + if [[ -n "${!e:-}" ]]; then + docker_flags+=("--env" "${e}=${!e}") + fi +done + +# If arguments are given, all arguments will become the commands run +# in the container, otherwise run TRAMPOLINE_BUILD_FILE. +if [[ $# -ge 1 ]]; then + log_yellow "Running the given commands '" "${@:1}" "' in the container." + readonly commands=("${@:1}") + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" + fi + docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" +else + log_yellow "Running the tests in a Docker container." + docker_flags+=("--entrypoint=${TRAMPOLINE_BUILD_FILE}") + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" + fi + docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" +fi + + +test_retval=$? + +if [[ ${test_retval} -eq 0 ]]; then + log_green "Build finished with ${test_retval}" +else + log_red "Build finished with ${test_retval}" +fi + +# Only upload it when the test passes. +if [[ "${update_cache}" == "true" ]] && \ + [[ $test_retval == 0 ]] && \ + [[ "${TRAMPOLINE_IMAGE_UPLOAD:-false}" == "true" ]]; then + log_yellow "Uploading the Docker image." + if docker push "${TRAMPOLINE_IMAGE}"; then + log_green "Finished uploading the Docker image." + else + log_red "Failed uploading the Docker image." + fi + # Call trampoline_after_upload_hook if it's defined. + if function_exists trampoline_after_upload_hook; then + trampoline_after_upload_hook + fi + +fi + +exit "${test_retval}" diff --git a/.trampolinerc b/.trampolinerc new file mode 100644 index 00000000..164613b9 --- /dev/null +++ b/.trampolinerc @@ -0,0 +1,51 @@ +# 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. + +# Template for .trampolinerc + +# Add required env vars here. +required_envvars+=( +) + +# Add env vars which are passed down into the container here. +pass_down_envvars+=( + "AUTORELEASE_PR" +) + +# Prevent unintentional override on the default image. +if [[ "${TRAMPOLINE_IMAGE_UPLOAD:-false}" == "true" ]] && \ + [[ -z "${TRAMPOLINE_IMAGE:-}" ]]; then + echo "Please set TRAMPOLINE_IMAGE if you want to upload the Docker image." + exit 1 +fi + +# Define the default value if it makes sense. +if [[ -z "${TRAMPOLINE_IMAGE_UPLOAD:-}" ]]; then + TRAMPOLINE_IMAGE_UPLOAD="" +fi + +if [[ -z "${TRAMPOLINE_IMAGE:-}" ]]; then + TRAMPOLINE_IMAGE="" +fi + +if [[ -z "${TRAMPOLINE_DOCKERFILE:-}" ]]; then + TRAMPOLINE_DOCKERFILE="" +fi + +if [[ -z "${TRAMPOLINE_BUILD_FILE:-}" ]]; then + TRAMPOLINE_BUILD_FILE="" +fi + +# Secret Manager secrets. +source ${PROJECT_ROOT}/.kokoro/populate-secrets.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f40cf64..5602df7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ [1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions +### [6.1.2](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.1...v6.1.2) (2020-10-19) + + +### Bug Fixes + +* update gcp-metadata to catch a json-bigint security fix ([#1078](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1078)) ([125fe09](https://www.github.com/googleapis/google-auth-library-nodejs/commit/125fe0924a2206ebb0c83ece9947524e7b135803)) + +### [6.1.1](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.0...v6.1.1) (2020-10-06) + + +### Bug Fixes + +* **deps:** upgrade gtoken ([#1064](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1064)) ([9116f24](https://www.github.com/googleapis/google-auth-library-nodejs/commit/9116f247486d6376feca505bbfa42a91d5e579e2)) + ## [6.1.0](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.0.8...v6.1.0) (2020-09-22) diff --git a/package.json b/package.json index 276ffab1..31cf40c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "6.1.0", + "version": "6.1.2", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { @@ -22,8 +22,8 @@ "ecdsa-sig-formatter": "^1.0.11", "fast-text-encoding": "^1.0.0", "gaxios": "^3.0.0", - "gcp-metadata": "^4.1.0", - "gtoken": "^5.0.0", + "gcp-metadata": "^4.2.0", + "gtoken": "^5.0.4", "jws": "^4.0.0", "lru-cache": "^6.0.0" }, @@ -67,7 +67,7 @@ "ts-loader": "^8.0.0", "typescript": "^3.8.3", "webpack": "^4.20.2", - "webpack-cli": "^3.1.1", + "webpack-cli": "^4.0.0", "@microsoft/api-documenter": "^7.8.10", "@microsoft/api-extractor": "^7.8.10" }, diff --git a/samples/package.json b/samples/package.json index e4fe63fc..f6d7e942 100644 --- a/samples/package.json +++ b/samples/package.json @@ -12,7 +12,7 @@ }, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^6.1.0", + "google-auth-library": "^6.1.2", "node-fetch": "^2.3.0", "opn": "^5.3.0", "server-destroy": "^1.0.1" diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 825d3408..20e14bd3 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -27,8 +27,13 @@ import {RefreshOptions} from './oauth2client'; export interface AwsClientOptions extends BaseExternalAccountClientOptions { credential_source: { environment_id: string; - region_url: string; - url: string; + // Region can also be determine 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; }; } @@ -53,8 +58,8 @@ interface AwsSecurityCredentials { */ export class AwsClient extends BaseExternalAccountClient { private readonly environmentId: string; - private readonly regionUrl: string; - private readonly securityCredentialsUrl: string; + private readonly regionUrl?: string; + private readonly securityCredentialsUrl?: string; private readonly regionalCredVerificationUrl: string; private awsRequestSigner: AwsRequestSigner | null; private region: string; @@ -72,6 +77,8 @@ export class AwsClient extends BaseExternalAccountClient { 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. @@ -81,11 +88,7 @@ export class AwsClient extends BaseExternalAccountClient { const envIdComponents = this.environmentId?.match(/^(aws)([\d]+)$/) || []; const envId = envIdComponents[1]; const envVersion = envIdComponents[2]; - if ( - envId !== 'aws' || - !this.regionUrl || - !this.regionalCredVerificationUrl - ) { + if (envId !== 'aws' || !this.regionalCredVerificationUrl) { throw new Error('No valid AWS "credential_source" provided'); } else if (parseInt(envVersion, 10) !== 1) { throw new Error( @@ -202,6 +205,12 @@ export class AwsClient extends BaseExternalAccountClient { 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', diff --git a/src/auth/awsrequestsigner.ts b/src/auth/awsrequestsigner.ts index 421a4b4b..60a70016 100644 --- a/src/auth/awsrequestsigner.ts +++ b/src/auth/awsrequestsigner.ts @@ -46,6 +46,31 @@ interface AwsSecurityCredentials { 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'; /** @@ -63,7 +88,7 @@ export class AwsRequestSigner { private readonly crypto: Crypto; /** - * Instantiates an AWS API handler used to send authenticated signed + * 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. @@ -102,17 +127,17 @@ export class AwsRequestSigner { const additionalAmzHeaders = amzOptions.headers; const awsSecurityCredentials = await this.getCredentials(); const uri = new URL(url); - const headerMap = await generateAuthenticationHeaderMap( - this.crypto, - uri.host, - uri.pathname, - uri.search.substr(1), + const headerMap = await generateAuthenticationHeaderMap({ + crypto: this.crypto, + host: uri.host, + canonicalUri: uri.pathname, + canonicalQuerystring: uri.search.substr(1), method, - this.region, - awsSecurityCredentials, + region: this.region, + securityCredentials: awsSecurityCredentials, requestPayload, - additionalAmzHeaders - ); + 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. @@ -146,6 +171,8 @@ export class AwsRequestSigner { * 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. @@ -159,10 +186,12 @@ async function sign( } /** - * Calculates the signature for AWS Signature Version 4. - * Based on: + * 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. @@ -187,26 +216,18 @@ async function getSigningKey( * Generates the authentication header map needed for generating the AWS * Signature Version 4 signed request. * - * @param accessKeyId The AWS access key ID. - * @param secretAccessKey The AWS secret access kye. - * @param token The AWS token. + * @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( - crypto: Crypto, - host: string, - canonicalUri: string, - canonicalQuerystring: string, - method: HttpMethod, - region: string, - securityCredentials: AwsSecurityCredentials, - requestPayload = '', - additionalAmzHeaders: Headers = {} + 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 = host.split('.')[0]; + const serviceName = options.host.split('.')[0]; const now = new Date(); // Format: '%Y%m%dT%H%M%SZ'. const amzDate = now @@ -223,14 +244,14 @@ async function generateAuthenticationHeaderMap( additionalAmzHeaders[key]; }); // Add AWS token if available. - if (securityCredentials.token) { + if (options.securityCredentials.token) { reformattedAdditionalAmzHeaders['x-amz-security-token'] = - securityCredentials.token; + options.securityCredentials.token; } // Header keys need to be sorted alphabetically. const amzHeaders = Object.assign( { - host, + 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 @@ -244,34 +265,34 @@ async function generateAuthenticationHeaderMap( }); const signedHeaders = signedHeadersList.join(';'); - const payloadHash = await crypto.sha256DigestHex(requestPayload); + const payloadHash = await options.crypto.sha256DigestHex(requestPayload); // https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html const canonicalRequest = - `${method}\n` + - `${canonicalUri}\n` + - `${canonicalQuerystring}\n` + + `${options.method}\n` + + `${options.canonicalUri}\n` + + `${options.canonicalQuerystring}\n` + `${canonicalHeaders}\n` + `${signedHeaders}\n` + `${payloadHash}`; - const credentialScope = `${dateStamp}/${region}/${serviceName}/${AWS_REQUEST_TYPE}`; + 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 crypto.sha256DigestHex(canonicalRequest)); + (await options.crypto.sha256DigestHex(canonicalRequest)); // https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html const signingKey = await getSigningKey( - crypto, - securityCredentials.secretAccessKey, + options.crypto, + options.securityCredentials.secretAccessKey, dateStamp, - region, + options.region, serviceName ); - const signature = await sign(crypto, signingKey, stringToSign); + 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=${securityCredentials.accessKeyId}/` + + `${AWS_ALGORITHM} Credential=${options.securityCredentials.accessKeyId}/` + `${credentialScope}, SignedHeaders=${signedHeaders}, ` + `Signature=${fromArrayBufferToHex(signature)}`; @@ -279,6 +300,6 @@ async function generateAuthenticationHeaderMap( // Do not return x-amz-date if date is available. amzDate: reformattedAdditionalAmzHeaders.date ? undefined : amzDate, authorizationHeader, - canonicalQuerystring, + canonicalQuerystring: options.canonicalQuerystring, }; } diff --git a/synth.metadata b/synth.metadata index 1da5b692..a630911d 100644 --- a/synth.metadata +++ b/synth.metadata @@ -3,15 +3,8 @@ { "git": { "name": ".", - "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "55b98171efffc2e7dfecd46ff3485382c3ab62f5" - } - }, - { - "git": { - "name": "synthtool", - "remote": "https://github.com/googleapis/synthtool.git", - "sha": "fdd03c161003ab97657cc0218f25c82c89ddf4b6" + "remote": "git@github.com:googleapis/google-auth-library-nodejs.git", + "sha": "084da82a00d0fe3352b51f72a3c64468dfcf1eef" } } ], @@ -54,15 +47,16 @@ ".kokoro/test.bat", ".kokoro/test.sh", ".kokoro/trampoline.sh", + ".kokoro/trampoline_v2.sh", ".mocharc.js", ".nycrc", ".prettierignore", ".prettierrc.js", + ".trampolinerc", "CODE_OF_CONDUCT.md", "CONTRIBUTING.md", "LICENSE", "api-extractor.json", - "renovate.json", - "samples/README.md" + "renovate.json" ] -} \ No newline at end of file +} diff --git a/system-test/fixtures/kitchen/package.json b/system-test/fixtures/kitchen/package.json index 076408f4..49f2f1fd 100644 --- a/system-test/fixtures/kitchen/package.json +++ b/system-test/fixtures/kitchen/package.json @@ -23,6 +23,6 @@ "null-loader": "^4.0.0", "ts-loader": "^8.0.0", "webpack": "^4.20.2", - "webpack-cli": "^3.1.1" + "webpack-cli": "^4.0.0" } } diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index 4793f742..ef12c5db 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -178,7 +178,6 @@ describe('AwsClient', () => { describe('Constructor', () => { const requiredCredentialSourceFields = [ 'environment_id', - 'region_url', 'regional_cred_verification_url', ]; requiredCredentialSourceFields.forEach(required => { @@ -384,6 +383,29 @@ describe('AwsClient', () => { 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()', () => { @@ -602,6 +624,31 @@ describe('AwsClient', () => { 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()', () => { From 31b9cfbb011c5fe36e083bb97e50df7344e82e71 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 1 Dec 2020 17:55:43 -0800 Subject: [PATCH 16/25] tests: fixes broken browser tests (#1102) --- .github/workflows/ci.yaml | 12 +- .kokoro/release/docs-devsite.sh | 4 + .kokoro/trampoline_v2.sh | 2 + .readme-partials.yaml | 398 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 7 + CODE_OF_CONDUCT.md | 123 +++++++--- README.md | 192 +++++++++++---- package.json | 6 +- samples/README.md | 2 +- samples/package.json | 2 +- src/auth/baseexternalclient.ts | 6 +- src/auth/identitypoolclient.ts | 6 +- synth.metadata | 64 +---- synth.py | 4 +- 14 files changed, 670 insertions(+), 158 deletions(-) create mode 100644 .readme-partials.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 06067a8c..891c9253 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [10, 12, 13] + node: [10, 12, 14, 15] steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 @@ -19,7 +19,9 @@ jobs: # The first installation step ensures that all of our production # dependencies work on the given Node.js version, this helps us find # dependencies that don't match our engines field: - - run: npm install --production --engine-strict + - run: npm install --production --engine-strict --ignore-scripts --no-package-lock + # Clean up the production install, before installing dev/production: + - run: rm -rf node_modules - run: npm install - run: npm test - name: coverage @@ -33,7 +35,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 14 - run: npm install - run: npm test - name: coverage @@ -47,7 +49,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 14 - run: npm install - run: npm run lint docs: @@ -56,6 +58,6 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12 + node-version: 14 - run: npm install - run: npm run docs-test diff --git a/.kokoro/release/docs-devsite.sh b/.kokoro/release/docs-devsite.sh index 0d11b7ae..7657be33 100755 --- a/.kokoro/release/docs-devsite.sh +++ b/.kokoro/release/docs-devsite.sh @@ -37,9 +37,13 @@ NAME=$(cat .repo-metadata.json | json name) mkdir ./_devsite cp ./yaml/$NAME/* ./_devsite +# Clean up TOC # Delete SharePoint item, see https://github.com/microsoft/rushstack/issues/1229 sed -i -e '1,3d' ./yaml/toc.yml sed -i -e 's/^ //' ./yaml/toc.yml +# Delete interfaces from TOC (name and uid) +sed -i -e '/name: I[A-Z]/{N;d;}' ./yaml/toc.yml +sed -i -e '/^ *\@google-cloud.*:interface/d' ./yaml/toc.yml cp ./yaml/toc.yml ./_devsite/toc.yml diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh index 5ae75f97..606d4321 100755 --- a/.kokoro/trampoline_v2.sh +++ b/.kokoro/trampoline_v2.sh @@ -125,6 +125,8 @@ pass_down_envvars=( "TRAMPOLINE_CI" # Indicates the version of the script. "TRAMPOLINE_VERSION" + # Contains path to build artifacts being executed. + "KOKORO_BUILD_ARTIFACTS_SUBDIR" ) log_yellow "Building with Trampoline ${TRAMPOLINE_VERSION}" diff --git a/.readme-partials.yaml b/.readme-partials.yaml new file mode 100644 index 00000000..ddcf3b21 --- /dev/null +++ b/.readme-partials.yaml @@ -0,0 +1,398 @@ +introduction: |- + This is Google's officially supported [node.js](http://nodejs.org/) client library for using OAuth 2.0 authorization and authentication with Google APIs. +body: |- + ## 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. + - [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. + + ## 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. + + #### 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. + + > This file is your *only copy* of these credentials. It should never be + > committed with your source code, and should be stored securely. + + Once downloaded, store the path to this file in the `GOOGLE_APPLICATION_CREDENTIALS` environment variable. + + #### Enable the API you want to use + + Before making your API call, you must be sure the API you're calling has been enabled. Go to **APIs & Auth** > **APIs** in the [Google Developers Console](https://console.cloud.google.com/) and enable the APIs you'd like to call. For the example below, you must enable the `DNS API`. + + + #### Choosing the correct credential type automatically + + Rather than manually creating an OAuth2 client, JWT client, or Compute client, the auth library can create the correct credential type for you, depending upon the environment your code is running under. + + For example, a JWT auth client will be created when your code is running on your local developer machine, and a Compute client will be created when the same code is running on Google Cloud Platform. If you need a specific set of scopes, you can pass those in the form of a string or an array to the `GoogleAuth` constructor. + + The code below shows how to retrieve a default credential type, depending upon the runtime environment. + + ```js + const {GoogleAuth} = require('google-auth-library'); + + /** + * Instead of specifying the type of client you'd like to use (JWT, OAuth2, etc) + * this library will automatically choose the right client based on the environment. + */ + 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(); + const url = `https://dns.googleapis.com/dns/v1/projects/${projectId}`; + const res = await client.request({ url }); + console.log(res.data); + } + + main().catch(console.error); + ``` + + ## OAuth2 + + This library comes with an [OAuth2](https://developers.google.com/identity/protocols/OAuth2) client that allows you to retrieve an access token and refreshes the token and retry the request seamlessly if you also provide an `expiry_date` and the token is expired. The basics of Google's OAuth2 implementation is explained on [Google Authorization and Authentication documentation](https://developers.google.com/accounts/docs/OAuth2Login). + + In the following examples, you may need a `CLIENT_ID`, `CLIENT_SECRET` and `REDIRECT_URL`. You can find these pieces of information by going to the [Developer Console](https://console.cloud.google.com/), clicking your project > APIs & auth > credentials. + + For more information about OAuth2 and how it works, [see here](https://developers.google.com/identity/protocols/OAuth2). + + #### A complete OAuth2 example + + Let's take a look at a complete example. + + ``` js + const {OAuth2Client} = require('google-auth-library'); + const http = require('http'); + const url = require('url'); + const open = require('open'); + const destroyer = require('server-destroy'); + + // Download your OAuth2 configuration from the Google + const keys = require('./oauth2.keys.json'); + + /** + * Start by acquiring a pre-authenticated oAuth2 client. + */ + async function main() { + const oAuth2Client = await getAuthenticatedClient(); + // Make a simple request to the People API using our pre-authenticated client. The `request()` method + // takes an GaxiosOptions object. Visit https://github.com/JustinBeckwith/gaxios. + const url = 'https://people.googleapis.com/v1/people/me?personFields=names'; + const res = await oAuth2Client.request({url}); + console.log(res.data); + + // After acquiring an access_token, you may want to check on the audience, expiration, + // or original scopes requested. You can do that with the `getTokenInfo` method. + const tokenInfo = await oAuth2Client.getTokenInfo( + oAuth2Client.credentials.access_token + ); + console.log(tokenInfo); + } + + /** + * Create a new OAuth2Client, and go through the OAuth2 content + * workflow. Return the full client to the callback. + */ + function getAuthenticatedClient() { + return new Promise((resolve, reject) => { + // create an oAuth client to authorize the API call. Secrets are kept in a `keys.json` file, + // which should be downloaded from the Google Developers Console. + const oAuth2Client = new OAuth2Client( + keys.web.client_id, + keys.web.client_secret, + keys.web.redirect_uris[0] + ); + + // Generate the url that will be used for the consent dialog. + const authorizeUrl = oAuth2Client.generateAuthUrl({ + access_type: 'offline', + scope: 'https://www.googleapis.com/auth/userinfo.profile', + }); + + // Open an http server to accept the oauth callback. In this simple example, the + // only request to our webserver is to /oauth2callback?code= + const server = http + .createServer(async (req, res) => { + try { + if (req.url.indexOf('/oauth2callback') > -1) { + // acquire the code from the querystring, and close the web server. + const qs = new url.URL(req.url, 'http://localhost:3000') + .searchParams; + const code = qs.get('code'); + console.log(`Code is ${code}`); + res.end('Authentication successful! Please return to the console.'); + server.destroy(); + + // Now that we have the code, use that to acquire tokens. + const r = await oAuth2Client.getToken(code); + // Make sure to set the credentials on the OAuth2 client. + oAuth2Client.setCredentials(r.tokens); + console.info('Tokens acquired.'); + resolve(oAuth2Client); + } + } catch (e) { + reject(e); + } + }) + .listen(3000, () => { + // open the browser to the authorize url to start the workflow + open(authorizeUrl, {wait: false}).then(cp => cp.unref()); + }); + destroyer(server); + }); + } + + main().catch(console.error); + ``` + + #### Handling token events + + This library will automatically obtain an `access_token`, and automatically refresh the `access_token` if a `refresh_token` is present. The `refresh_token` is only returned on the [first authorization](https://github.com/googleapis/google-api-nodejs-client/issues/750#issuecomment-304521450), so if you want to make sure you store it safely. An easy way to make sure you always store the most recent tokens is to use the `tokens` event: + + ```js + const client = await auth.getClient(); + + client.on('tokens', (tokens) => { + if (tokens.refresh_token) { + // store the refresh_token in my database! + console.log(tokens.refresh_token); + } + console.log(tokens.access_token); + }); + + const url = `https://dns.googleapis.com/dns/v1/projects/${projectId}`; + const res = await client.request({ url }); + // The `tokens` event would now be raised if this was the first request + ``` + + #### Retrieve access token + With the code returned, you can ask for an access token as shown below: + + ``` js + const tokens = await oauth2Client.getToken(code); + // Now tokens contains an access_token and an optional refresh_token. Save them. + oauth2Client.setCredentials(tokens); + ``` + + #### Obtaining a new Refresh Token + If you need to obtain a new `refresh_token`, ensure the call to `generateAuthUrl` sets the `access_type` to `offline`. The refresh token will only be returned for the first authorization by the user. To force consent, set the `prompt` property to `consent`: + + ```js + // Generate the url that will be used for the consent dialog. + const authorizeUrl = oAuth2Client.generateAuthUrl({ + // To get a refresh token, you MUST set access_type to `offline`. + access_type: 'offline', + // set the appropriate scopes + scope: 'https://www.googleapis.com/auth/userinfo.profile', + // A refresh token is only returned the first time the user + // consents to providing access. For illustration purposes, + // setting the prompt to 'consent' will force this consent + // every time, forcing a refresh_token to be returned. + prompt: 'consent' + }); + ``` + + #### Checking `access_token` information + After obtaining and storing an `access_token`, at a later time you may want to go check the expiration date, + original scopes, or audience for the token. To get the token info, you can use the `getTokenInfo` method: + + ```js + // after acquiring an oAuth2Client... + const tokenInfo = await oAuth2Client.getTokenInfo('my-access-token'); + + // take a look at the scopes originally provisioned for the access token + console.log(tokenInfo.scopes); + ``` + + This method will throw if the token is invalid. + + #### OAuth2 with Installed Apps (Electron) + If you're authenticating with OAuth2 from an installed application (like Electron), you may not want to embed your `client_secret` inside of the application sources. To work around this restriction, you can choose the `iOS` application type when creating your OAuth2 credentials in the [Google Developers console](https://console.cloud.google.com/): + + ![application type](https://user-images.githubusercontent.com/534619/36553844-3f9a863c-17b2-11e8-904a-29f6cd5f807a.png) + + If using the `iOS` type, when creating the OAuth2 client you won't need to pass a `client_secret` into the constructor: + ```js + const oAuth2Client = new OAuth2Client({ + clientId: , + redirectUri: + }); + ``` + + ## JSON Web Tokens + The Google Developers Console provides a `.json` file that you can use to configure a JWT auth client and authenticate your requests, for example when using a service account. + + ``` js + const {JWT} = require('google-auth-library'); + const keys = require('./jwt.keys.json'); + + async function main() { + const client = new JWT({ + email: keys.client_email, + key: keys.private_key, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + const url = `https://dns.googleapis.com/dns/v1/projects/${keys.project_id}`; + const res = await client.request({url}); + console.log(res.data); + } + + main().catch(console.error); + ``` + + The parameters for the JWT auth client including how to use it with a `.pem` file are explained in [samples/jwt.js](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/jwt.js). + + #### Loading credentials from environment variables + Instead of loading credentials from a key file, you can also provide them using an environment variable and the `GoogleAuth.fromJSON()` method. This is particularly convenient for systems that deploy directly from source control (Heroku, App Engine, etc). + + Start by exporting your credentials: + + ``` + $ export CREDS='{ + "type": "service_account", + "project_id": "your-project-id", + "private_key_id": "your-private-key-id", + "private_key": "your-private-key", + "client_email": "your-client-email", + "client_id": "your-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": "your-cert-url" + }' + ``` + Now you can create a new client from the credentials: + + ```js + const {auth} = require('google-auth-library'); + + // load the environment variable with our keys + const keysEnvVar = process.env['CREDS']; + if (!keysEnvVar) { + throw new Error('The $CREDS environment variable was not found!'); + } + const keys = JSON.parse(keysEnvVar); + + async function main() { + // load the JWT or UserRefreshClient from the keys + const client = auth.fromJSON(keys); + client.scopes = ['https://www.googleapis.com/auth/cloud-platform']; + const url = `https://dns.googleapis.com/dns/v1/projects/${keys.project_id}`; + const res = await client.request({url}); + console.log(res.data); + } + + main().catch(console.error); + ``` + + #### Using a Proxy + You can set the `HTTPS_PROXY` or `https_proxy` environment variables to proxy HTTPS requests. When `HTTPS_PROXY` or `https_proxy` are set, they will be used to proxy SSL requests that do not have an explicit proxy configuration option present. + + ## Compute + If your application is running on Google Cloud Platform, you can authenticate using the default service account or by specifying a specific service account. + + **Note**: In most cases, you will want to use [Application Default Credentials](#choosing-the-correct-credential-type-automatically). Direct use of the `Compute` class is for very specific scenarios. + + ``` js + const {auth, Compute} = require('google-auth-library'); + + async function main() { + const client = new Compute({ + // Specifying the service account email is optional. + serviceAccountEmail: 'my-service-account@example.com' + }); + const projectId = await auth.getProjectId(); + const url = `https://dns.googleapis.com/dns/v1/projects/${projectId}`; + const res = await client.request({url}); + console.log(res.data); + } + + main().catch(console.error); + ``` + + ## Working with ID Tokens + ### Fetching ID Tokens + If your application is running on Cloud Run or Cloud Functions, or using Cloud Identity-Aware + Proxy (IAP), you will need to fetch an ID token to access your application. For + this, use the method `getIdTokenClient` on the `GoogleAuth` client. + + For invoking Cloud Run services, your service account will need the + [`Cloud Run Invoker`](https://cloud.google.com/run/docs/authenticating/service-to-service) + IAM permission. + + For invoking Cloud Functions, your service account will need the + [`Function Invoker`](https://cloud.google.com/functions/docs/securing/authenticating#function-to-function) + IAM permission. + + ``` js + // Make a request to a protected Cloud Run service. + const {GoogleAuth} = require('google-auth-library'); + + async function main() { + const url = 'https://cloud-run-1234-uc.a.run.app'; + const auth = new GoogleAuth(); + const client = await auth.getIdTokenClient(url); + const res = await client.request({url}); + console.log(res.data); + } + + main().catch(console.error); + ``` + + A complete example can be found in [`samples/idtokens-serverless.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-serverless.js). + + For invoking Cloud Identity-Aware Proxy, you will need to pass the Client ID + used when you set up your protected resource as the target audience. + + ``` js + // Make a request to a protected Cloud Identity-Aware Proxy (IAP) resource + const {GoogleAuth} = require('google-auth-library'); + + async function main() + const targetAudience = 'iap-client-id'; + const url = 'https://iap-url.com'; + const auth = new GoogleAuth(); + const client = await auth.getIdTokenClient(targetAudience); + const res = await client.request({url}); + console.log(res.data); + } + + main().catch(console.error); + ``` + + A complete example can be found in [`samples/idtokens-iap.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-iap.js). + + ### Verifying ID Tokens + + If you've [secured your IAP app with signed headers](https://cloud.google.com/iap/docs/signed-headers-howto), + you can use this library to verify the IAP header: + + ```js + const {OAuth2Client} = require('google-auth-library'); + // Expected audience for App Engine. + const expectedAudience = `/projects/your-project-number/apps/your-project-id`; + // IAP issuer + const issuers = ['https://cloud.google.com/iap']; + // Verify the token. OAuth2Client throws an Error if verification fails + const oAuth2Client = new OAuth2Client(); + const response = await oAuth2Client.getIapCerts(); + const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync( + idToken, + response.pubkeys, + expectedAudience, + issuers + ); + + // Print out the info contained in the IAP ID token + console.log(ticket) + ``` + + A complete example can be found in [`samples/verifyIdToken-iap.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/verifyIdToken-iap.js). diff --git a/CHANGELOG.md b/CHANGELOG.md index 5602df7d..4fb98e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions +### [6.1.3](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.2...v6.1.3) (2020-10-22) + + +### Bug Fixes + +* **deps:** update dependency gaxios to v4 ([#1086](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1086)) ([f2678ff](https://www.github.com/googleapis/google-auth-library-nodejs/commit/f2678ff5f8f5a0ee33924278b58e0a6e3122cb12)) + ### [6.1.2](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.1...v6.1.2) (2020-10-19) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 46b2a08e..2add2547 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,43 +1,94 @@ -# Contributor Code of Conduct + +# Code of Conduct -As contributors and maintainers of this project, -and in the interest of fostering an open and welcoming community, -we pledge to respect all people who contribute through reporting issues, -posting feature requests, updating documentation, -submitting pull requests or patches, and other activities. +## Our Pledge -We are committed to making participation in this project -a harassment-free experience for everyone, -regardless of level of experience, gender, gender identity and expression, -sexual orientation, disability, personal appearance, -body size, race, ethnicity, age, religion, or nationality. +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members Examples of unacceptable behavior by participants include: -* The use of sexualized language or imagery -* Personal attacks -* Trolling or insulting/derogatory comments -* Public or private harassment -* Publishing other's private information, -such as physical or electronic -addresses, without explicit permission -* Other unethical or unprofessional conduct. +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct. -By adopting this Code of Conduct, -project maintainers commit themselves to fairly and consistently -applying these principles to every aspect of managing this project. -Project maintainers who do not follow or enforce the Code of Conduct -may be permanently removed from the project team. - -This code of conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. - -Instances of abusive, harassing, or otherwise unacceptable behavior -may be reported by opening an issue -or contacting one or more of the project maintainers. - -This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, -available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +This Code of Conduct also applies outside the project spaces when the Project +Steward has a reasonable belief that an individual's behavior may have a +negative impact on the project or its community. + +## Conflict Resolution + +We do not believe that all conflict is bad; healthy debate and disagreement +often yield positive results. However, it is never okay to be disrespectful or +to engage in behavior that violates the project’s code of conduct. + +If you see someone violating the code of conduct, you are encouraged to address +the behavior directly with those involved. Many issues can be resolved quickly +and easily, and this gives people more control over the outcome of their +dispute. If you are unable to resolve the matter for any reason, or if the +behavior is threatening or harassing, report it. We are dedicated to providing +an environment where participants feel welcome and safe. + +Reports should be directed to *googleapis-stewards@google.com*, the +Project Steward(s) for *Google Cloud Client Libraries*. It is the Project Steward’s duty to +receive and address reported violations of the code of conduct. They will then +work with a committee consisting of representatives from the Open Source +Programs Office and the Google Open Source Strategy team. If for any reason you +are uncomfortable reaching out to the Project Steward, please email +opensource@google.com. + +We will investigate every complaint, but you may not receive a direct response. +We will use our discretion in determining when and how to follow up on reported +incidents, which may range from not taking action to permanent expulsion from +the project and project-sponsored spaces. We will notify the accused of the +report and provide them an opportunity to discuss it before any action is taken. +The identity of the reporter will be omitted from the details of the report +supplied to the accused. In potentially harmful situations, such as ongoing +harassment or threats to anyone's safety, we may take action without notice. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, +available at +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html \ No newline at end of file diff --git a/README.md b/README.md index f29c488a..68e4dde1 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,49 @@ -Google Inc. logo +[//]: # "This README.md file is auto-generated, all changes to this file will be lost." +[//]: # "To regenerate it, use `python -m synthtool`." +Google Cloud Platform logo -# Google Auth Library +# [Google Auth Library: Node.js Client](https://github.com/googleapis/google-auth-library-nodejs) -[![npm version][npmimg]][npm] -[![codecov][codecov-image]][codecov-url] -[![Dependencies][david-dm-img]][david-dm] -[![Known Vulnerabilities][snyk-image]][snyk-url] +[![release level](https://img.shields.io/badge/release%20level-general%20availability%20%28GA%29-brightgreen.svg?style=flat)](https://cloud.google.com/terms/launch-stages) +[![npm version](https://img.shields.io/npm/v/google-auth-library.svg)](https://www.npmjs.org/package/google-auth-library) +[![codecov](https://img.shields.io/codecov/c/github/googleapis/google-auth-library-nodejs/master.svg?style=flat)](https://codecov.io/gh/googleapis/google-auth-library-nodejs) -This is Google's officially supported [node.js][node] client library for using OAuth 2.0 authorization and authentication with Google APIs. -## Installation -This library is distributed on `npm`. To add it as a dependency, run the following command: -``` sh -$ npm install google-auth-library + +This is Google's officially supported [node.js](http://nodejs.org/) client library for using OAuth 2.0 authorization and authentication with Google APIs. + + +A comprehensive list of changes in each version may be found in +[the CHANGELOG](https://github.com/googleapis/google-auth-library-nodejs/blob/master/CHANGELOG.md). + +* [Google Auth Library Node.js Client API Reference][client-docs] +* [Google Auth Library Documentation][product-docs] +* [github.com/googleapis/google-auth-library-nodejs](https://github.com/googleapis/google-auth-library-nodejs) + +Read more about the client libraries for Cloud APIs, including the older +Google APIs Client Libraries, in [Client Libraries Explained][explained]. + +[explained]: https://cloud.google.com/apis/docs/client-libraries-explained + +**Table of contents:** + + +* [Quickstart](#quickstart) + + * [Installing the client library](#installing-the-client-library) + +* [Samples](#samples) +* [Versioning](#versioning) +* [Contributing](#contributing) +* [License](#license) + +## Quickstart + +### Installing the client library + +```bash +npm install google-auth-library ``` ## Ways to authenticate @@ -24,13 +54,13 @@ This library provides a variety of ways to authenticate to your Google services. - [Google Compute](#compute) - Directly use a service account on Google Cloud Platform. Useful for server->server or server->API communication. ## Application Default Credentials -This library provides an implementation of [Application Default Credentials][] for Node.js. The [Application Default Credentials][] provide a simple way to get authorization credentials for use in calling Google APIs. +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. #### 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][devconsole] and select **Service account** from the **Add credentials** dropdown. +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. > This file is your *only copy* of these credentials. It should never be > committed with your source code, and should be stored securely. @@ -39,7 +69,7 @@ Once downloaded, store the path to this file in the `GOOGLE_APPLICATION_CREDENTI #### Enable the API you want to use -Before making your API call, you must be sure the API you're calling has been enabled. Go to **APIs & Auth** > **APIs** in the [Google Developers Console][devconsole] and enable the APIs you'd like to call. For the example below, you must enable the `DNS API`. +Before making your API call, you must be sure the API you're calling has been enabled. Go to **APIs & Auth** > **APIs** in the [Google Developers Console](https://console.cloud.google.com/) and enable the APIs you'd like to call. For the example below, you must enable the `DNS API`. #### Choosing the correct credential type automatically @@ -54,9 +84,9 @@ The code below shows how to retrieve a default credential type, depending upon t const {GoogleAuth} = require('google-auth-library'); /** - * Instead of specifying the type of client you'd like to use (JWT, OAuth2, etc) - * this library will automatically choose the right client based on the environment. - */ +* Instead of specifying the type of client you'd like to use (JWT, OAuth2, etc) +* this library will automatically choose the right client based on the environment. +*/ async function main() { const auth = new GoogleAuth({ scopes: 'https://www.googleapis.com/auth/cloud-platform' @@ -73,11 +103,11 @@ main().catch(console.error); ## OAuth2 -This library comes with an [OAuth2][oauth] client that allows you to retrieve an access token and refreshes the token and retry the request seamlessly if you also provide an `expiry_date` and the token is expired. The basics of Google's OAuth2 implementation is explained on [Google Authorization and Authentication documentation][authdocs]. +This library comes with an [OAuth2](https://developers.google.com/identity/protocols/OAuth2) client that allows you to retrieve an access token and refreshes the token and retry the request seamlessly if you also provide an `expiry_date` and the token is expired. The basics of Google's OAuth2 implementation is explained on [Google Authorization and Authentication documentation](https://developers.google.com/accounts/docs/OAuth2Login). -In the following examples, you may need a `CLIENT_ID`, `CLIENT_SECRET` and `REDIRECT_URL`. You can find these pieces of information by going to the [Developer Console][devconsole], clicking your project > APIs & auth > credentials. +In the following examples, you may need a `CLIENT_ID`, `CLIENT_SECRET` and `REDIRECT_URL`. You can find these pieces of information by going to the [Developer Console](https://console.cloud.google.com/), clicking your project > APIs & auth > credentials. -For more information about OAuth2 and how it works, [see here][oauth]. +For more information about OAuth2 and how it works, [see here](https://developers.google.com/identity/protocols/OAuth2). #### A complete OAuth2 example @@ -94,8 +124,8 @@ const destroyer = require('server-destroy'); const keys = require('./oauth2.keys.json'); /** - * Start by acquiring a pre-authenticated oAuth2 client. - */ +* Start by acquiring a pre-authenticated oAuth2 client. +*/ async function main() { const oAuth2Client = await getAuthenticatedClient(); // Make a simple request to the People API using our pre-authenticated client. The `request()` method @@ -113,9 +143,9 @@ async function main() { } /** - * Create a new OAuth2Client, and go through the OAuth2 content - * workflow. Return the full client to the callback. - */ +* Create a new OAuth2Client, and go through the OAuth2 content +* workflow. Return the full client to the callback. +*/ function getAuthenticatedClient() { return new Promise((resolve, reject) => { // create an oAuth client to authorize the API call. Secrets are kept in a `keys.json` file, @@ -230,9 +260,9 @@ console.log(tokenInfo.scopes); This method will throw if the token is invalid. #### OAuth2 with Installed Apps (Electron) -If you're authenticating with OAuth2 from an installed application (like Electron), you may not want to embed your `client_secret` inside of the application sources. To work around this restriction, you can choose the `iOS` application type when creating your OAuth2 credentials in the [Google Developers console][devconsole]: +If you're authenticating with OAuth2 from an installed application (like Electron), you may not want to embed your `client_secret` inside of the application sources. To work around this restriction, you can choose the `iOS` application type when creating your OAuth2 credentials in the [Google Developers console](https://console.cloud.google.com/): -![application type][apptype] +![application type](https://user-images.githubusercontent.com/534619/36553844-3f9a863c-17b2-11e8-904a-29f6cd5f807a.png) If using the `iOS` type, when creating the OAuth2 client you won't need to pass a `client_secret` into the constructor: ```js @@ -412,34 +442,94 @@ console.log(ticket) A complete example can be found in [`samples/verifyIdToken-iap.js`](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/verifyIdToken-iap.js). -## Questions/problems? -* Ask your development related questions on [Stack Overflow][stackoverflow]. -* If you've found an bug/issue, please [file it on GitHub][bugs]. +## Samples + +Samples are in the [`samples/`](https://github.com/googleapis/google-auth-library-nodejs/tree/master/samples) directory. The samples' `README.md` +has instructions for running the samples. + +| Sample | Source Code | Try it | +| --------------------------- | --------------------------------- | ------ | +| Adc | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/adc.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/adc.js,samples/README.md) | +| Compute | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/compute.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/compute.js,samples/README.md) | +| Credentials | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/credentials.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/credentials.js,samples/README.md) | +| Headers | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/headers.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/headers.js,samples/README.md) | +| ID Tokens for Identity-Aware Proxy (IAP) | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-iap.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-iap.js,samples/README.md) | +| ID Tokens for Serverless | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/idtokens-serverless.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/idtokens-serverless.js,samples/README.md) | +| Jwt | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/jwt.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/jwt.js,samples/README.md) | +| Keepalive | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/keepalive.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/keepalive.js,samples/README.md) | +| Keyfile | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/keyfile.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/keyfile.js,samples/README.md) | +| Oauth2-code Verifier | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/oauth2-codeVerifier.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/oauth2-codeVerifier.js,samples/README.md) | +| Oauth2 | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/oauth2.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/oauth2.js,samples/README.md) | +| Sign Blob | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/signBlob.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/signBlob.js,samples/README.md) | +| Verifying ID Tokens from Identity-Aware Proxy (IAP) | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/verifyIdToken-iap.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/verifyIdToken-iap.js,samples/README.md) | +| Verify Id Token | [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/verifyIdToken.js) | [![Open in Cloud Shell][shell_img]](https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/googleapis/google-auth-library-nodejs&page=editor&open_in_editor=samples/verifyIdToken.js,samples/README.md) | + + + +The [Google Auth Library Node.js Client API Reference][client-docs] documentation +also contains samples. + +## Supported Node.js Versions + +Our client libraries follow the [Node.js release schedule](https://nodejs.org/en/about/releases/). +Libraries are compatible with all current _active_ and _maintenance_ versions of +Node.js. + +Client libraries targeting some end-of-life versions of Node.js are available, and +can be installed via npm [dist-tags](https://docs.npmjs.com/cli/dist-tag). +The dist-tags follow the naming convention `legacy-(version)`. + +_Legacy Node.js versions are supported as a best effort:_ + +* Legacy versions will not be tested in continuous integration. +* Some security patches may not be able to be backported. +* Dependencies will not be kept up-to-date, and features will not be backported. + +#### Legacy tags available + +* `legacy-8`: install client libraries from this dist-tag for versions + compatible with Node.js 8. + +## Versioning + +This library follows [Semantic Versioning](http://semver.org/). + + +This library is considered to be **General Availability (GA)**. This means it +is stable; the code surface will not change in backwards-incompatible ways +unless absolutely necessary (e.g. because of critical security issues) or with +an extensive deprecation period. Issues and requests against **GA** libraries +are addressed with the highest priority. + + + + + +More Information: [Google Cloud Platform Launch Stages][launch_stages] + +[launch_stages]: https://cloud.google.com/terms/launch-stages ## Contributing -See [CONTRIBUTING][contributing]. +Contributions welcome! See the [Contributing Guide](https://github.com/googleapis/google-auth-library-nodejs/blob/master/CONTRIBUTING.md). + +Please note that this `README.md`, the `samples/README.md`, +and a variety of configuration files in this repository (including `.nycrc` and `tsconfig.json`) +are generated from a central template. To edit one of these files, make an edit +to its template in this +[directory](https://github.com/googleapis/synthtool/tree/master/synthtool/gcp/templates/node_library). ## License -This library is licensed under Apache 2.0. Full license text is available in [LICENSE][copying]. - -[Application Default Credentials]: https://cloud.google.com/docs/authentication/getting-started -[apptype]: https://user-images.githubusercontent.com/534619/36553844-3f9a863c-17b2-11e8-904a-29f6cd5f807a.png -[authdocs]: https://developers.google.com/accounts/docs/OAuth2Login -[bugs]: https://github.com/googleapis/google-auth-library-nodejs/issues -[codecov-image]: https://codecov.io/gh/googleapis/google-auth-library-nodejs/branch/master/graph/badge.svg -[codecov-url]: https://codecov.io/gh/googleapis/google-auth-library-nodejs -[contributing]: https://github.com/googleapis/google-auth-library-nodejs/blob/master/CONTRIBUTING.md -[copying]: https://github.com/googleapis/google-auth-library-nodejs/tree/master/LICENSE -[david-dm-img]: https://david-dm.org/googleapis/google-auth-library-nodejs/status.svg -[david-dm]: https://david-dm.org/googleapis/google-auth-library-nodejs -[node]: http://nodejs.org/ -[npmimg]: https://img.shields.io/npm/v/google-auth-library.svg -[npm]: https://www.npmjs.org/package/google-auth-library -[oauth]: https://developers.google.com/identity/protocols/OAuth2 -[snyk-image]: https://snyk.io/test/github/googleapis/google-auth-library-nodejs/badge.svg -[snyk-url]: https://snyk.io/test/github/googleapis/google-auth-library-nodejs -[stackoverflow]: http://stackoverflow.com/questions/tagged/google-auth-library-nodejs -[devconsole]: https://console.cloud.google.com/ +Apache Version 2.0 + +See [LICENSE](https://github.com/googleapis/google-auth-library-nodejs/blob/master/LICENSE) + +[client-docs]: https://googleapis.dev/nodejs/google-auth-library/latest +[product-docs]: https://cloud.google.com/docs/authentication/ +[shell_img]: https://gstatic.com/cloudssh/images/open-btn.png +[projects]: https://console.cloud.google.com/project +[billing]: https://support.google.com/cloud/answer/6293499#enable-billing + +[auth]: https://cloud.google.com/docs/authentication/getting-started diff --git a/package.json b/package.json index 31cf40c8..fc810fda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "6.1.2", + "version": "6.1.3", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { @@ -21,7 +21,7 @@ "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "fast-text-encoding": "^1.0.0", - "gaxios": "^3.0.0", + "gaxios": "^4.0.0", "gcp-metadata": "^4.2.0", "gtoken": "^5.0.4", "jws": "^4.0.0", @@ -49,7 +49,7 @@ "karma": "^5.0.0", "karma-chrome-launcher": "^3.0.0", "karma-coverage": "^2.0.0", - "karma-firefox-launcher": "^1.1.0", + "karma-firefox-launcher": "^2.0.0", "karma-mocha": "^2.0.0", "karma-remap-coverage": "^0.1.5", "karma-sourcemap-loader": "^0.3.7", diff --git a/samples/README.md b/samples/README.md index 40571df3..c4f4e5be 100644 --- a/samples/README.md +++ b/samples/README.md @@ -6,7 +6,7 @@ [![Open in Cloud Shell][shell_img]][shell_link] - +This is Google's officially supported [node.js](http://nodejs.org/) client library for using OAuth 2.0 authorization and authentication with Google APIs. ## Table of Contents diff --git a/samples/package.json b/samples/package.json index f6d7e942..e4fa34e5 100644 --- a/samples/package.json +++ b/samples/package.json @@ -12,7 +12,7 @@ }, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^6.1.2", + "google-auth-library": "^6.1.3", "node-fetch": "^2.3.0", "opn": "^5.3.0", "server-destroy": "^1.0.1" diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index f30edd53..43920349 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -445,9 +445,9 @@ export abstract class BaseExternalAccountClient extends AuthClient { }, responseType: 'json', }; - const response = await this.transporter.request< - IamGenerateAccessTokenResponse - >(opts); + const response = await this.transporter.request( + opts + ); const successResponse = response.data; return { access_token: successResponse.accessToken, diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index eef78622..5f6057f5 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -22,7 +22,11 @@ import { } from './baseexternalclient'; import {RefreshOptions} from './oauth2client'; -const readFile = promisify(fs.readFile); +// 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 ?? (() => {})); type SubjectTokenFormatType = 'json' | 'text'; diff --git a/synth.metadata b/synth.metadata index a630911d..61327138 100644 --- a/synth.metadata +++ b/synth.metadata @@ -3,60 +3,16 @@ { "git": { "name": ".", - "remote": "git@github.com:googleapis/google-auth-library-nodejs.git", - "sha": "084da82a00d0fe3352b51f72a3c64468dfcf1eef" + "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", + "sha": "a5682b825b2716f983669c919ca8fef9ebbc3004" + } + }, + { + "git": { + "name": "synthtool", + "remote": "https://github.com/googleapis/synthtool.git", + "sha": "15013eff642a7e7e855aed5a29e6e83c39beba2a" } } - ], - "generatedFiles": [ - ".eslintignore", - ".eslintrc.json", - ".gitattributes", - ".github/ISSUE_TEMPLATE/bug_report.md", - ".github/ISSUE_TEMPLATE/feature_request.md", - ".github/ISSUE_TEMPLATE/support_request.md", - ".github/PULL_REQUEST_TEMPLATE.md", - ".github/release-please.yml", - ".github/workflows/ci.yaml", - ".kokoro/.gitattributes", - ".kokoro/common.cfg", - ".kokoro/continuous/node10/common.cfg", - ".kokoro/continuous/node10/docs.cfg", - ".kokoro/continuous/node10/test.cfg", - ".kokoro/continuous/node12/common.cfg", - ".kokoro/continuous/node12/lint.cfg", - ".kokoro/continuous/node12/samples-test.cfg", - ".kokoro/continuous/node12/system-test.cfg", - ".kokoro/continuous/node12/test.cfg", - ".kokoro/docs.sh", - ".kokoro/lint.sh", - ".kokoro/populate-secrets.sh", - ".kokoro/presubmit/node10/common.cfg", - ".kokoro/presubmit/node12/common.cfg", - ".kokoro/presubmit/node12/samples-test.cfg", - ".kokoro/presubmit/node12/system-test.cfg", - ".kokoro/presubmit/node12/test.cfg", - ".kokoro/publish.sh", - ".kokoro/release/docs-devsite.cfg", - ".kokoro/release/docs-devsite.sh", - ".kokoro/release/docs.cfg", - ".kokoro/release/docs.sh", - ".kokoro/release/publish.cfg", - ".kokoro/samples-test.sh", - ".kokoro/system-test.sh", - ".kokoro/test.bat", - ".kokoro/test.sh", - ".kokoro/trampoline.sh", - ".kokoro/trampoline_v2.sh", - ".mocharc.js", - ".nycrc", - ".prettierignore", - ".prettierrc.js", - ".trampolinerc", - "CODE_OF_CONDUCT.md", - "CONTRIBUTING.md", - "LICENSE", - "api-extractor.json", - "renovate.json" ] -} +} \ No newline at end of file diff --git a/synth.py b/synth.py index 84d0f33e..294439b0 100644 --- a/synth.py +++ b/synth.py @@ -8,6 +8,4 @@ common_templates = gcp.CommonTemplates() templates = common_templates.node_library() -s.copy(templates, excludes=["README.md"]) -node.install() -node.fix() +s.copy(templates) From ba919497088b6d10bdf8245fd53c3bd55a7ac31a Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 21 Jan 2021 11:30:07 -0800 Subject: [PATCH 17/25] test: add integration tests for external account clients (#1117) --- CHANGELOG.md | 7 + CONTRIBUTING.md | 13 +- README.md | 3 +- package.json | 12 +- samples/package.json | 3 +- samples/scripts/externalclient-setup.js | 201 +++++++++++++++++++ samples/test/externalclient.test.js | 250 ++++++++++++++++++++++++ src/auth/oauth2client.ts | 2 +- synth.metadata | 4 +- test/test.oauth2.ts | 19 +- 10 files changed, 489 insertions(+), 25 deletions(-) create mode 100755 samples/scripts/externalclient-setup.js create mode 100644 samples/test/externalclient.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb98e95..60cfecae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ [1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions +### [6.1.4](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.3...v6.1.4) (2020-12-22) + + +### Bug Fixes + +* move accessToken to headers instead of parameter ([#1108](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1108)) ([67b0cc3](https://www.github.com/googleapis/google-auth-library-nodejs/commit/67b0cc3077860a1583bcf18ce50aeff58bbb5496)) + ### [6.1.3](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.2...v6.1.3) (2020-10-22) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6c4cf01..72c44cad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,13 @@ accept your pull requests. 1. Title your pull request following [Conventional Commits](https://www.conventionalcommits.org/) styling. 1. Submit a pull request. +### Before you begin + +1. [Select or create a Cloud Platform project][projects]. +1. [Set up authentication with a service account][auth] so you can access the + API from your local workstation. + + ## Running the tests 1. [Prepare your environment for Node.js setup][setup]. @@ -51,11 +58,9 @@ accept your pull requests. npm test # Run sample integration tests. - gcloud auth application-default login npm run samples-test # Run all system tests. - gcloud auth application-default login npm run system-test 1. Lint (and maybe fix) any changes: @@ -63,3 +68,7 @@ accept your pull requests. npm run fix [setup]: https://cloud.google.com/nodejs/docs/setup +[projects]: https://console.cloud.google.com/project +[billing]: https://support.google.com/cloud/answer/6293499#enable-billing + +[auth]: https://cloud.google.com/docs/authentication/getting-started \ No newline at end of file diff --git a/README.md b/README.md index 68e4dde1..da54ef90 100644 --- a/README.md +++ b/README.md @@ -445,8 +445,7 @@ A complete example can be found in [`samples/verifyIdToken-iap.js`](https://gith ## Samples -Samples are in the [`samples/`](https://github.com/googleapis/google-auth-library-nodejs/tree/master/samples) directory. The samples' `README.md` -has instructions for running the samples. +Samples are in the [`samples/`](https://github.com/googleapis/google-auth-library-nodejs/tree/master/samples) directory. Each sample's `README.md` has instructions for running its sample. | Sample | Source Code | Try it | | --------------------------- | --------------------------------- | ------ | diff --git a/package.json b/package.json index fc810fda..522cd0d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "6.1.3", + "version": "6.1.4", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { @@ -17,6 +17,7 @@ "client library" ], "dependencies": { + "12": "^1.0.2", "arrify": "^2.0.0", "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -29,6 +30,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", @@ -43,7 +46,7 @@ "c8": "^7.0.0", "chai": "^4.2.0", "codecov": "^3.0.2", - "execa": "^4.0.0", + "execa": "^5.0.0", "gts": "^2.0.0", "is-docker": "^2.0.0", "karma": "^5.0.0", @@ -67,9 +70,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 +85,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 e4fa34e5..ed947aec 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": { @@ -12,7 +13,7 @@ }, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^6.1.3", + "google-auth-library": "^6.1.4", "node-fetch": "^2.3.0", "opn": "^5.3.0", "server-destroy": "^1.0.1" diff --git a/samples/scripts/externalclient-setup.js b/samples/scripts/externalclient-setup.js new file mode 100755 index 00000000..9bba9b93 --- /dev/null +++ b/samples/scripts/externalclient-setup.js @@ -0,0 +1,201 @@ +// 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 and URL-sourced OIDC-based 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. +// +// 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. +// +// 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. Print out the STS audience field associated with the created provider +// after the setup completes successfully so that it can be used in the +// tests. This will be copied and used as the global AUDIENCE constant in +// samples/test/externalclient.test.js. +// 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 a new +// audience is 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. + */ +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(''); +}; + +/** + * 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 + * @return {Promise} A promise that resolves with the STS audience + * corresponding with the generated workload identity pool OIDC provider. + */ +async function main() { + 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 providerId = `oidc-${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 aud = `//iam.googleapis.com/${poolResourcePath}/providers/${providerId}`; + + // 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. 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. + let found = false; + bindings.forEach(binding => { + if (binding.role === 'roles/iam.workloadIdentityUser') { + found = true; + binding.members = [ + `principal://iam.googleapis.com/${poolResourcePath}/subject/${sub}`, + ]; + } + }); + if (!found) { + bindings.push({ + role: 'roles/iam.workloadIdentityUser', + members: [ + `principal://iam.googleapis.com/${poolResourcePath}/subject/${sub}`, + ], + }); + } + 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=${providerId}`, + 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: [aud], + }, + }, + }); + return aud; +} + +// On execution, the generated audience will be printed to the screen. +// This should be used as the STS audeince 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().then(console.log).catch(console.error); diff --git a/samples/test/externalclient.test.js b/samples/test/externalclient.test.js new file mode 100644 index 00000000..02f67c45 --- /dev/null +++ b/samples/test/externalclient.test.js @@ -0,0 +1,250 @@ +// 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. + +// Make sure to run the setup in samples/scripts/externalclient-setup.js +// and copy the logged audience string to the AUDIENCE constant in this file +// before running this test suite. Once that is done, this test can be run +// indefinitely. +// The only requirement for this test suite 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: +// 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. +// +// 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. +// 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} = 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; +}; + +/** + * 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(''); +}; + +// The STS audience. Copy from output of +// samples/scripts/externalclient-setup.js. +const AUDIENCE = + '//iam.googleapis.com/projects/1046198160504/locations/global/' + + 'workloadIdentityPools/pool-ersg6slz1q/providers/oidc-ersg6slz1q'; +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', () => { + const aud = AUDIENCE; + let httpServer; + let clientEmail; + let oidcToken; + 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')); + clientEmail = keys.client_email; + + // Generate the Google OIDC token. This will be used as the external + // subject token for the following tests. + oidcToken = await generateGoogleIdToken(auth, aud, clientEmail); + }); + + 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: aud, + 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('node adc', { + 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: aud, + 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('node adc', { + env: { + GOOGLE_APPLICATION_CREDENTIALS: configFilePath, + }, + }); + // Confirm expected script output. + assert.match(output, /DNS Info:/); + }); +}); diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index 046863f5..47809098 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -1018,9 +1018,9 @@ export class OAuth2Client extends AuthClient { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Bearer ${accessToken}`, }, url: OAuth2Client.GOOGLE_TOKEN_INFO_URL, - data: querystring.stringify({access_token: accessToken}), }); const info = Object.assign( { diff --git a/synth.metadata b/synth.metadata index 61327138..14382daa 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "a5682b825b2716f983669c919ca8fef9ebbc3004" + "sha": "67b0cc3077860a1583bcf18ce50aeff58bbb5496" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "15013eff642a7e7e855aed5a29e6e83c39beba2a" + "sha": "363fe305e9ce34a6cd53951c6ee5f997094b54ee" } } ] diff --git a/test/test.oauth2.ts b/test/test.oauth2.ts index e53fbcaa..62fd416a 100644 --- a/test/test.oauth2.ts +++ b/test/test.oauth2.ts @@ -1322,18 +1322,13 @@ describe('oauth2', () => { expires_in: 1234, }; - const scope = nock(baseUrl) - .post( - '/tokeninfo', - qs.stringify({ - access_token: accessToken, - }), - { - reqheaders: { - 'content-type': 'application/x-www-form-urlencoded', - }, - } - ) + const scope = nock(baseUrl, { + reqheaders: { + 'content-type': 'application/x-www-form-urlencoded', + authorization: `Bearer ${accessToken}`, + }, + }) + .post('/tokeninfo', () => true) .reply(200, tokenInfo); const info = await client.getTokenInfo(accessToken); From 161983ee2efa5910420409e1866349c67113e27c Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 27 Jan 2021 09:22:53 -0800 Subject: [PATCH 18/25] test: add integration tests for AWS external account clients (#1122) --- package.json | 2 +- samples/scripts/externalclient-setup.js | 138 +++++++++++++++--- samples/test/externalclient.test.js | 178 +++++++++++++++++++++--- src/auth/googleauth.ts | 18 ++- test/test.googleauth.ts | 49 +++++++ test/test.jwt.ts | 2 +- 6 files changed, 344 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 522cd0d4..fff01a50 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "execa": "^5.0.0", "gts": "^2.0.0", "is-docker": "^2.0.0", - "karma": "^5.0.0", + "karma": "^6.0.0", "karma-chrome-launcher": "^3.0.0", "karma-coverage": "^2.0.0", "karma-firefox-launcher": "^2.0.0", diff --git a/samples/scripts/externalclient-setup.js b/samples/scripts/externalclient-setup.js index 9bba9b93..798decf2 100755 --- a/samples/scripts/externalclient-setup.js +++ b/samples/scripts/externalclient-setup.js @@ -14,12 +14,16 @@ // 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 and URL-sourced OIDC-based credentials. +// 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). @@ -33,6 +37,22 @@ // 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 @@ -41,14 +61,22 @@ // 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. Print out the STS audience field associated with the created provider -// after the setup completes successfully so that it can be used in the -// tests. This will be copied and used as the global AUDIENCE constant in -// samples/test/externalclient.test.js. +// 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 a new -// audience is printed. If run multiple times, it is advisable to delete +// +// 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. @@ -86,10 +114,18 @@ const generateRandomString = length => { * 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 - * @return {Promise} A promise that resolves with the STS audience - * corresponding with the generated workload identity pool OIDC provider. + * 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() { +async function main(config) { let response; const keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { @@ -98,7 +134,8 @@ async function main() { const keys = JSON.parse(await readFile(keyFile, 'utf8')); const suffix = generateRandomString(10); const poolId = `pool-${suffix}`; - const providerId = `oidc-${suffix}`; + const oidcProviderId = `oidc-${suffix}`; + const awsProviderId = `aws-${suffix}`; const projectId = keys.project_id; const clientEmail = keys.client_email; const sub = keys.client_id; @@ -125,7 +162,8 @@ async function main() { // 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 aud = `//iam.googleapis.com/${poolResourcePath}/providers/${providerId}`; + 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. @@ -137,16 +175,21 @@ async function main() { }); const bindings = response.data.bindings || []; // If not found, add roles/iam.workloadIdentityUser role binding to the - // workload identity pool member. We will use the value mapped to - // google.subject. + // 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}`, ]; } }); @@ -155,6 +198,9 @@ async function main() { 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}`, ], }); } @@ -176,7 +222,7 @@ async function main() { url: `https://iam.googleapis.com/v1beta/projects/${projectId}/` + `locations/global/workloadIdentityPools/${poolId}/providers?` + - `workloadIdentityPoolProviderId=${providerId}`, + `workloadIdentityPoolProviderId=${oidcProviderId}`, method: 'POST', data: { displayName: 'Test OIDC provider', @@ -186,16 +232,68 @@ async function main() { }, oidc: { issuerUri: 'https://accounts.google.com', - allowedAudiences: [aud], + 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 aud; + + return { + oidcAudience, + awsAudience, + }; } -// On execution, the generated audience will be printed to the screen. -// This should be used as the STS audeince in test/externalclient.test.js. +// 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().then(console.log).catch(console.error); +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 index 02f67c45..05f699ef 100644 --- a/samples/test/externalclient.test.js +++ b/samples/test/externalclient.test.js @@ -12,26 +12,56 @@ // 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 audience string to the AUDIENCE constant in this file -// before running this test suite. Once that is done, this test can be run -// indefinitely. -// The only requirement for this test suite is to set the environment +// 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: +// 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 @@ -42,7 +72,7 @@ const {assert} = require('chai'); const {describe, it, before, afterEach} = require('mocha'); const fs = require('fs'); const {promisify} = require('util'); -const {GoogleAuth} = require('google-auth-library'); +const {GoogleAuth, DefaultTransporter} = require('google-auth-library'); const os = require('os'); const path = require('path'); const http = require('http'); @@ -86,6 +116,59 @@ const generateGoogleIdToken = async (auth, aud, clientEmail) => { 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. @@ -105,11 +188,20 @@ const generateRandomString = length => { return chars.join(''); }; -// The STS audience. Copy from output of -// samples/scripts/externalclient-setup.js. -const AUDIENCE = +////////////////////////////////////////////////////////////////////////// +// 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-ersg6slz1q/providers/oidc-ersg6slz1q'; + '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); @@ -117,10 +209,10 @@ const exec = promisify(cp.exec); const keyFile = process.env.GOOGLE_APPLICATION_CREDENTIALS; describe('samples for external-account', () => { - const aud = AUDIENCE; let httpServer; let clientEmail; let oidcToken; + let awsCredentials; const port = 8088; const suffix = generateRandomString(10); const configFilePath = path.join(os.tmpdir(), `config-${suffix}.json`); @@ -131,11 +223,21 @@ describe('samples for external-account', () => { 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 tests. - oidcToken = await generateGoogleIdToken(auth, aud, clientEmail); + // 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 () => { @@ -158,7 +260,7 @@ describe('samples for external-account', () => { // retrieved from a file location. const config = { type: 'external_account', - audience: aud, + 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: @@ -173,8 +275,9 @@ describe('samples for external-account', () => { // Run sample script with GOOGLE_APPLICATION_CREDENTIALS envvar // pointing to the temporarily created configuration file. - const output = await execAsync('node adc', { + const output = await execAsync(`${process.execPath} adc`, { env: { + ...process.env, GOOGLE_APPLICATION_CREDENTIALS: configFilePath, }, }); @@ -188,7 +291,7 @@ describe('samples for external-account', () => { // retrieved from a local server. const config = { type: 'external_account', - audience: aud, + 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: @@ -239,8 +342,47 @@ describe('samples for external-account', () => { // Run sample script with GOOGLE_APPLICATION_CREDENTIALS environment // variable pointing to the temporarily created configuration file. - const output = await execAsync('node adc', { + 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, }, }); diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index d4982b22..d410ac1a 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -552,9 +552,21 @@ export class GoogleAuth { .on('data', chunk => (s += chunk)) .on('end', () => { try { - const data = JSON.parse(s); - const r = this._cacheClientFromJSON(data, options); - return resolve(r); + try { + const data = JSON.parse(s); + const r = this._cacheClientFromJSON(data, options); + return resolve(r); + } catch (err) { + // If we failed parsing this.keyFileName, assume that it + // is a PEM or p12 certificate: + if (!this.keyFilename) throw err; + const client = new JWT({ + ...this.clientOptions, + keyFile: this.keyFilename, + }); + this.cachedCredential = client; + return resolve(client); + } } catch (err) { return reject(err); } diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index c7517d36..0c9c59e2 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -16,6 +16,7 @@ import * as assert from 'assert'; import {describe, it, beforeEach, afterEach} from 'mocha'; import * as child_process from 'child_process'; import * as crypto from 'crypto'; +import {CredentialRequest} from '../src/auth/credentials'; import * as fs from 'fs'; import { BASE_PATH, @@ -59,6 +60,8 @@ describe('googleauth', () => { const instancePath = `${BASE_PATH}/instance`; const svcAccountPath = `${instancePath}/service-accounts/?recursive=true`; const API_KEY = 'test-123'; + const PEM_PATH = './test/fixtures/private.pem'; + const P12_PATH = './test/fixtures/key.p12'; const STUB_PROJECT = 'my-awesome-project'; const ENDPOINT = '/events:report'; const RESPONSE_BODY = 'RESPONSE_BODY'; @@ -91,6 +94,11 @@ describe('googleauth', () => { 'gcloud', 'application_default_credentials.json' ); + function createGTokenMock(body: CredentialRequest) { + return nock('https://www.googleapis.com') + .post('/oauth2/v4/token') + .reply(200, body); + } describe('googleauth', () => { let auth: GoogleAuth; @@ -2195,4 +2203,45 @@ describe('googleauth', () => { }); }); }); + + // 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); + }); }); diff --git a/test/test.jwt.ts b/test/test.jwt.ts index dd569a03..aa70ea44 100644 --- a/test/test.jwt.ts +++ b/test/test.jwt.ts @@ -118,8 +118,8 @@ describe('jwt', () => { scopes: 'http://foo', subject: 'bar@subjectaccount.com', }); - const scope = createGTokenMock({access_token: 'initial-access-token'}); + jwt.authorize(() => { scope.done(); assert.strictEqual('http://foo', jwt.gtoken!.scope); From 42b060589dc334046ea47448426d51dfeacfa49c Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 2 Feb 2021 15:53:25 -0800 Subject: [PATCH 19/25] docs: add instructions for using workload identity federation (#1125) --- .kokoro/samples-test.sh | 6 +- .kokoro/system-test.sh | 6 +- .kokoro/test.sh | 6 +- .kokoro/trampoline_v2.sh | 2 +- CHANGELOG.md | 14 +++ README.md | 212 ++++++++++++++++++++++++++++++++++++++- package.json | 2 +- samples/package.json | 2 +- src/auth/oauth2client.ts | 2 +- synth.metadata | 4 +- test/test.googleauth.ts | 41 ++++++++ test/test.refresh.ts | 37 +++++++ 12 files changed, 318 insertions(+), 16 deletions(-) diff --git a/.kokoro/samples-test.sh b/.kokoro/samples-test.sh index bab7ba4e..950f8483 100755 --- a/.kokoro/samples-test.sh +++ b/.kokoro/samples-test.sh @@ -39,14 +39,14 @@ if [ -f samples/package.json ]; then npm link ../ npm install cd .. - # If tests are running against master, configure Build Cop + # If tests are running against master, configure flakybot # to open issues on failures: if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]] || [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"nightly"* ]]; then export MOCHA_REPORTER_OUTPUT=test_output_sponge_log.xml export MOCHA_REPORTER=xunit cleanup() { - chmod +x $KOKORO_GFILE_DIR/linux_amd64/buildcop - $KOKORO_GFILE_DIR/linux_amd64/buildcop + chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot + $KOKORO_GFILE_DIR/linux_amd64/flakybot } trap cleanup EXIT HUP fi diff --git a/.kokoro/system-test.sh b/.kokoro/system-test.sh index 8a084004..319d1e0e 100755 --- a/.kokoro/system-test.sh +++ b/.kokoro/system-test.sh @@ -33,14 +33,14 @@ fi npm install -# If tests are running against master, configure Build Cop +# If tests are running against master, configure flakybot # to open issues on failures: if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]] || [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"nightly"* ]]; then export MOCHA_REPORTER_OUTPUT=test_output_sponge_log.xml export MOCHA_REPORTER=xunit cleanup() { - chmod +x $KOKORO_GFILE_DIR/linux_amd64/buildcop - $KOKORO_GFILE_DIR/linux_amd64/buildcop + chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot + $KOKORO_GFILE_DIR/linux_amd64/flakybot } trap cleanup EXIT HUP fi diff --git a/.kokoro/test.sh b/.kokoro/test.sh index 5be385fe..5d6383fc 100755 --- a/.kokoro/test.sh +++ b/.kokoro/test.sh @@ -21,14 +21,14 @@ export NPM_CONFIG_PREFIX=${HOME}/.npm-global cd $(dirname $0)/.. npm install -# If tests are running against master, configure Build Cop +# If tests are running against master, configure flakybot # to open issues on failures: if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"continuous"* ]] || [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"nightly"* ]]; then export MOCHA_REPORTER_OUTPUT=test_output_sponge_log.xml export MOCHA_REPORTER=xunit cleanup() { - chmod +x $KOKORO_GFILE_DIR/linux_amd64/buildcop - $KOKORO_GFILE_DIR/linux_amd64/buildcop + chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot + $KOKORO_GFILE_DIR/linux_amd64/flakybot } trap cleanup EXIT HUP fi diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh index 606d4321..4d031121 100755 --- a/.kokoro/trampoline_v2.sh +++ b/.kokoro/trampoline_v2.sh @@ -162,7 +162,7 @@ if [[ -n "${KOKORO_BUILD_ID:-}" ]]; then "KOKORO_GITHUB_COMMIT" "KOKORO_GITHUB_PULL_REQUEST_NUMBER" "KOKORO_GITHUB_PULL_REQUEST_COMMIT" - # For Build Cop Bot + # For flakybot "KOKORO_GITHUB_COMMIT_URL" "KOKORO_GITHUB_PULL_REQUEST_URL" ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60cfecae..bfc0dbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ [1]: https://www.npmjs.com/package/google-auth-library-nodejs?activeTab=versions +### [6.1.6](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.5...v6.1.6) (2021-01-27) + + +### Bug Fixes + +* call addSharedMetadataHeaders even when token has not expired ([#1116](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1116)) ([aad043d](https://www.github.com/googleapis/google-auth-library-nodejs/commit/aad043d20df3f1e44f56c58a21f15000b6fe970d)) + +### [6.1.5](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.4...v6.1.5) (2021-01-22) + + +### Bug Fixes + +* support PEM and p12 when using factory ([#1120](https://www.github.com/googleapis/google-auth-library-nodejs/issues/1120)) ([c2ead4c](https://www.github.com/googleapis/google-auth-library-nodejs/commit/c2ead4cc7650f100b883c9296fce628f17085992)) + ### [6.1.4](https://www.github.com/googleapis/google-auth-library-nodejs/compare/v6.1.3...v6.1.4) (2020-12-22) 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/package.json b/package.json index fff01a50..206a5089 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "google-auth-library", - "version": "6.1.4", + "version": "6.1.6", "author": "Google Inc.", "description": "Google APIs Authentication Client Library for Node.js", "engines": { diff --git a/samples/package.json b/samples/package.json index ed947aec..b9b5064d 100644 --- a/samples/package.json +++ b/samples/package.json @@ -13,7 +13,7 @@ }, "license": "Apache-2.0", "dependencies": { - "google-auth-library": "^6.1.4", + "google-auth-library": "^6.1.6", "node-fetch": "^2.3.0", "opn": "^5.3.0", "server-destroy": "^1.0.1" diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index 47809098..20572d33 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -786,7 +786,7 @@ export class OAuth2Client extends AuthClient { const headers = { Authorization: thisCreds.token_type + ' ' + thisCreds.access_token, }; - return {headers}; + return {headers: this.addSharedMetadataHeaders(headers)}; } if (this.apiKey) { diff --git a/synth.metadata b/synth.metadata index 14382daa..04d7caca 100644 --- a/synth.metadata +++ b/synth.metadata @@ -4,14 +4,14 @@ "git": { "name": ".", "remote": "https://github.com/googleapis/google-auth-library-nodejs.git", - "sha": "67b0cc3077860a1583bcf18ce50aeff58bbb5496" + "sha": "c27dec9e07d4e6873fdc486d25ed7b50e9560ea6" } }, { "git": { "name": "synthtool", "remote": "https://github.com/googleapis/synthtool.git", - "sha": "363fe305e9ce34a6cd53951c6ee5f997094b54ee" + "sha": "57c23fa5705499a4181095ced81f0ee0933b64f6" } } ] diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 0c9c59e2..4d242fa4 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -2244,4 +2244,45 @@ describe('googleauth', () => { 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); + }); }); diff --git a/test/test.refresh.ts b/test/test.refresh.ts index 8fd59cc5..2e06d7de 100644 --- a/test/test.refresh.ts +++ b/test/test.refresh.ts @@ -152,4 +152,41 @@ describe('refresh', () => { assert.strictEqual(headers['x-goog-user-project'], 'my-quota-project'); req.done(); }); + + it('getRequestHeaders should populate x-goog-user-project header if quota_project_id present and token has not expired', async () => { + const stream = fs.createReadStream( + './test/fixtures/config-with-quota/.config/gcloud/application_default_credentials.json' + ); + const eagerRefreshThresholdMillis = 10; + const refresh = new UserRefreshClient({ + eagerRefreshThresholdMillis, + }); + await refresh.fromStream(stream); + refresh.credentials = { + access_token: 'woot', + refresh_token: 'jwt-placeholder', + expiry_date: new Date().getTime() + eagerRefreshThresholdMillis + 1000, + }; + const headers = await refresh.getRequestHeaders(); + assert.strictEqual(headers['x-goog-user-project'], 'my-quota-project'); + }); + + it('getRequestHeaders should populate x-goog-user-project header if quota_project_id present and token has expired', async () => { + const req = nock('https://oauth2.googleapis.com') + .post('/token') + .reply(200, {}); + const stream = fs.createReadStream( + './test/fixtures/config-with-quota/.config/gcloud/application_default_credentials.json' + ); + const refresh = new UserRefreshClient(); + await refresh.fromStream(stream); + refresh.credentials = { + access_token: 'woot', + refresh_token: 'jwt-placeholder', + expiry_date: new Date().getTime() - 1, + }; + const headers = await refresh.getRequestHeaders(); + assert.strictEqual(headers['x-goog-user-project'], 'my-quota-project'); + req.done(); + }); }); From 9b44aa7350e8d07b5472c506143eaa344fcb0892 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Tue, 2 Feb 2021 17:05:31 -0800 Subject: [PATCH 20/25] Fixes outdated license headers. --- src/auth/awsclient.ts | 2 +- src/auth/awsrequestsigner.ts | 2 +- src/auth/baseexternalclient.ts | 2 +- src/auth/externalclient.ts | 2 +- src/auth/identitypoolclient.ts | 2 +- src/auth/oauth2common.ts | 2 +- src/auth/stscredentials.ts | 2 +- test/externalclienthelper.ts | 2 +- test/test.awsclient.ts | 2 +- test/test.awsrequestsigner.ts | 2 +- test/test.baseexternalclient.ts | 2 +- test/test.externalclient.ts | 2 +- test/test.identitypoolclient.ts | 2 +- test/test.oauth2common.ts | 2 +- test/test.stscredentials.ts | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 20e14bd3..86533786 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/src/auth/awsrequestsigner.ts b/src/auth/awsrequestsigner.ts index 60a70016..a0e8870c 100644 --- a/src/auth/awsrequestsigner.ts +++ b/src/auth/awsrequestsigner.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 43920349..b28c88b5 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/src/auth/externalclient.ts b/src/auth/externalclient.ts index 67d02a57..191c6953 100644 --- a/src/auth/externalclient.ts +++ b/src/auth/externalclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 5f6057f5..e9f6c966 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/src/auth/oauth2common.ts b/src/auth/oauth2common.ts index ad4db99e..34d1bb6d 100644 --- a/src/auth/oauth2common.ts +++ b/src/auth/oauth2common.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/src/auth/stscredentials.ts b/src/auth/stscredentials.ts index 64c65e4f..c86e76cf 100644 --- a/src/auth/stscredentials.ts +++ b/src/auth/stscredentials.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts index 314fb1a8..391616e2 100644 --- a/test/externalclienthelper.ts +++ b/test/externalclienthelper.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/test.awsclient.ts b/test/test.awsclient.ts index ef12c5db..b318cea2 100644 --- a/test/test.awsclient.ts +++ b/test/test.awsclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/test.awsrequestsigner.ts b/test/test.awsrequestsigner.ts index fbc1eada..ebe3824e 100644 --- a/test/test.awsrequestsigner.ts +++ b/test/test.awsrequestsigner.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index e9975c7c..a5ede057 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/test.externalclient.ts b/test/test.externalclient.ts index fda3ec56..4d1f95fe 100644 --- a/test/test.externalclient.ts +++ b/test/test.externalclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts index 446b792d..9543e8b7 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/test.oauth2common.ts b/test/test.oauth2common.ts index d9e0dd62..5f57649b 100644 --- a/test/test.oauth2common.ts +++ b/test/test.oauth2common.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. diff --git a/test/test.stscredentials.ts b/test/test.stscredentials.ts index 7efb03ea..72e17119 100644 --- a/test/test.stscredentials.ts +++ b/test/test.stscredentials.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. From 0723adefabf8f2a0d93b16e4d9bb69018a4e390c Mon Sep 17 00:00:00 2001 From: "Benjamin E. Coe" Date: Wed, 3 Feb 2021 09:00:39 -0800 Subject: [PATCH 21/25] Update package.json --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 1502a396..a67ab2b4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "client library" ], "dependencies": { - "12": "^1.0.2", "arrify": "^2.0.0", "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", From 954cefa28ccb3c7ccc07d9b0624146572463edd9 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 3 Feb 2021 18:10:21 -0800 Subject: [PATCH 22/25] Updates `generateRandomString()` Updates `generateRandomString()` to address review comments. --- samples/scripts/externalclient-setup.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/samples/scripts/externalclient-setup.js b/samples/scripts/externalclient-setup.js index 798decf2..e8a5ce4b 100755 --- a/samples/scripts/externalclient-setup.js +++ b/samples/scripts/externalclient-setup.js @@ -94,17 +94,16 @@ const readFile = promisify(fs.readFile); * @param {number} length The length of the string to generate. * @return {string} A random string of the provided length. */ -const generateRandomString = length => { +function generateRandomString(length) { const chars = []; const allowedChars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - while (length > 0) { + for (let i = 0; i < length; i++) { chars.push( allowedChars.charAt(Math.floor(Math.random() * allowedChars.length)) ); - length--; } return chars.join(''); -}; +} /** * Creates a workload identity pool with an OIDC provider which will accept From 2e759891506f84059c2981de90f3024caddaf3bf Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Fri, 5 Feb 2021 13:42:07 -0800 Subject: [PATCH 23/25] Fixes typos and udpates aws environment_id parsing. --- browser-test/test.crypto.ts | 8 ++++---- src/auth/authclient.ts | 4 ++-- src/auth/awsclient.ts | 12 +++++------- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/browser-test/test.crypto.ts b/browser-test/test.crypto.ts index 8028ecfa..5da17f98 100644 --- a/browser-test/test.crypto.ts +++ b/browser-test/test.crypto.ts @@ -115,14 +115,14 @@ describe('Browser crypto tests', () => { const key = 'key'; const expectedHexHash = 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; - const extectedHash = new Uint8Array( + 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, extectedHash.buffer); + assert.deepStrictEqual(calculatedHash, expectedHash.buffer); }); it('using an ArrayBuffer key', async () => { @@ -132,14 +132,14 @@ describe('Browser crypto tests', () => { .buffer as ArrayBuffer; const expectedHexHash = 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8'; - const extectedHash = new Uint8Array( + 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, extectedHash.buffer); + assert.deepStrictEqual(calculatedHash, expectedHash.buffer); }); }); diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index 9f85faec..bc1dea8b 100644 --- a/src/auth/authclient.ts +++ b/src/auth/authclient.ts @@ -50,7 +50,7 @@ export interface CredentialsClient { /** * The main authentication interface. It takes an optional url which when - * present is the endpoint> being accessed, and returns a Promise which + * present is the endpoint being accessed, and returns a Promise which * resolves with authorization header fields. * * The result has the form: @@ -101,7 +101,7 @@ export abstract class AuthClient /** * The main authentication interface. It takes an optional url which when - * present is the endpoint> being accessed, and returns a Promise which + * present is the endpoint being accessed, and returns a Promise which * resolves with authorization header fields. * * The result has the form: diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 86533786..6b873488 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -27,7 +27,7 @@ import {RefreshOptions} from './oauth2client'; export interface AwsClientOptions extends BaseExternalAccountClientOptions { credential_source: { environment_id: string; - // Region can also be determine from the AWS_REGION environment variable. + // 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 @@ -85,14 +85,12 @@ export class AwsClient extends BaseExternalAccountClient { this.securityCredentialsUrl = options.credential_source.url; this.regionalCredVerificationUrl = options.credential_source.regional_cred_verification_url; - const envIdComponents = this.environmentId?.match(/^(aws)([\d]+)$/) || []; - const envId = envIdComponents[1]; - const envVersion = envIdComponents[2]; - if (envId !== 'aws' || !this.regionalCredVerificationUrl) { + const match = this.environmentId?.match(/^(aws)(\d+)$/); + if (!match || !this.regionalCredVerificationUrl) { throw new Error('No valid AWS "credential_source" provided'); - } else if (parseInt(envVersion, 10) !== 1) { + } else if (parseInt(match[2], 10) !== 1) { throw new Error( - `aws version "${envVersion}" is not supported in the current build.` + `aws version "${match[2]}" is not supported in the current build.` ); } this.awsRequestSigner = null; From 6f06cb3840e68cffa644b1070310732f1a9e0f31 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Fri, 5 Feb 2021 14:01:43 -0800 Subject: [PATCH 24/25] Fixes readability of some variables. --- src/auth/baseexternalclient.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index b28c88b5..31359559 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -299,17 +299,18 @@ export abstract class BaseExternalAccountClient extends AuthClient { opts: GaxiosOptions, retry = false ): Promise> { - let r2: GaxiosResponse; + let response: GaxiosResponse; try { - const r = await this.getRequestHeaders(); + const requestHeaders = await this.getRequestHeaders(); opts.headers = opts.headers || {}; - if (r && r['x-goog-user-project']) { - opts.headers['x-goog-user-project'] = r['x-goog-user-project']; + if (requestHeaders && requestHeaders['x-goog-user-project']) { + opts.headers['x-goog-user-project'] = + requestHeaders['x-goog-user-project']; } - if (r && r.Authorization) { - opts.headers.Authorization = r.Authorization; + if (requestHeaders && requestHeaders.Authorization) { + opts.headers.Authorization = requestHeaders.Authorization; } - r2 = await this.transporter.request(opts); + response = await this.transporter.request(opts); } catch (e) { const res = (e as GaxiosError).response; if (res) { @@ -328,12 +329,12 @@ export abstract class BaseExternalAccountClient extends AuthClient { this.forceRefreshOnFailure ) { await this.refreshAccessTokenAsync(); - return this.requestAsync(opts, true); + return await this.requestAsync(opts, true); } } throw e; } - return r2; + return response; } /** From dfb68ea7955e381b31206d6896562e51ae34677b Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Fri, 5 Feb 2021 15:28:24 -0800 Subject: [PATCH 25/25] Addresses all remaining review comments. --- src/auth/baseexternalclient.ts | 9 ++++----- src/auth/googleauth.ts | 16 ++++++++-------- src/auth/identitypoolclient.ts | 19 ++++++++++--------- src/crypto/node/crypto.ts | 17 +++++------------ .../aws-security-credentials-fake.json | 2 +- test/fixtures/external-account-cred.json | 2 +- test/fixtures/external-subject-token.json | 2 +- 7 files changed, 30 insertions(+), 37 deletions(-) diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 31359559..2d0aa10c 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -414,12 +414,11 @@ export abstract class BaseExternalAccountClient extends AuthClient { private getProjectNumber(audience: string): string | null { // STS audience pattern: // //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/... - const components = audience.split('/'); - const projectIndex = components.indexOf('projects'); - if (projectIndex !== -1 && projectIndex !== components.length - 1) { - return components[projectIndex + 1] || null; + const match = audience.match(/\/projects\/([^/]+)/); + if (!match) { + return null; } - return null; + return match[1]; } /** diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index d410ac1a..cd6f6040 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -667,15 +667,15 @@ 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) { - const creds = await this.getClient(); - try { - return await (creds as BaseExternalAccountClient).getProjectId(); - } catch (e) { - return null; - } + 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; } - return null; } /** diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index e9f6c966..33badafa 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -27,6 +27,8 @@ import {RefreshOptions} from './oauth2client'; // 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'; @@ -117,14 +119,13 @@ export class IdentityPoolClient extends BaseExternalAccountClient { this.formatType, this.formatSubjectTokenFieldName ); - } else { - return await this.getTokenFromUrl( - this.url!, - this.formatType, - this.formatSubjectTokenFieldName, - this.headers - ); } + return await this.getTokenFromUrl( + this.url!, + this.formatType, + this.formatSubjectTokenFieldName, + this.headers + ); } /** @@ -147,9 +148,9 @@ export class IdentityPoolClient extends BaseExternalAccountClient { try { // Resolve path to actual file in case of symlink. Expect a thrown error // if not resolvable. - filePath = fs.realpathSync(filePath); + filePath = await realpath(filePath); - if (!fs.lstatSync(filePath).isFile()) { + if (!(await lstat(filePath)).isFile()) { throw new Error(); } } catch (err) { diff --git a/src/crypto/node/crypto.ts b/src/crypto/node/crypto.ts index be08a71e..eaaff400 100644 --- a/src/crypto/node/crypto.ts +++ b/src/crypto/node/crypto.ts @@ -86,12 +86,10 @@ export class NodeCrypto implements Crypto { * @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; + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ); } /** @@ -100,10 +98,5 @@ function toArrayBuffer(buffer: Buffer): ArrayBuffer { * @return The Buffer representation of the input. */ function toBuffer(arrayBuffer: ArrayBuffer): Buffer { - const buf = Buffer.alloc(arrayBuffer.byteLength); - const view = new Uint8Array(arrayBuffer); - for (let i = 0; i < buf.length; ++i) { - buf[i] = view[i]; - } - return buf; + return Buffer.from(arrayBuffer); } diff --git a/test/fixtures/aws-security-credentials-fake.json b/test/fixtures/aws-security-credentials-fake.json index 76e7688a..8bd8ca85 100644 --- a/test/fixtures/aws-security-credentials-fake.json +++ b/test/fixtures/aws-security-credentials-fake.json @@ -6,4 +6,4 @@ "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" -} \ No newline at end of file +} diff --git a/test/fixtures/external-account-cred.json b/test/fixtures/external-account-cred.json index df7ba5a6..9e7d029b 100644 --- a/test/fixtures/external-account-cred.json +++ b/test/fixtures/external-account-cred.json @@ -6,4 +6,4 @@ "credential_source": { "file": "./test/fixtures/external-subject-token.txt" } -} \ No newline at end of file +} diff --git a/test/fixtures/external-subject-token.json b/test/fixtures/external-subject-token.json index a47ec341..6bba08dc 100644 --- a/test/fixtures/external-subject-token.json +++ b/test/fixtures/external-subject-token.json @@ -1,3 +1,3 @@ { "access_token": "HEADER.SIMULATED_JWT_PAYLOAD.SIGNATURE" -} \ No newline at end of file +}