From c3415f1fe7055fd800aaafd0b45ae09a5e0a17ff Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Thu, 6 Aug 2020 23:42:29 -0700 Subject: [PATCH 1/4] feat: implements the OAuth token exchange spec based on rfc8693 --- src/auth/stscredentials.ts | 179 ++++++++++++++++ test/test.stscredentials.ts | 403 ++++++++++++++++++++++++++++++++++++ 2 files changed, 582 insertions(+) create mode 100644 src/auth/stscredentials.ts create mode 100644 test/test.stscredentials.ts diff --git a/src/auth/stscredentials.ts b/src/auth/stscredentials.ts new file mode 100644 index 00000000..6beee2e4 --- /dev/null +++ b/src/auth/stscredentials.ts @@ -0,0 +1,179 @@ +// 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 { + grantType: string; + resource?: string; + audience?: string; + scope?: string[]; + requestedTokenType?: string; + subjectToken: string; + subjectTokenType: string; + actingParty?: { + actorToken: string; + 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; + [key: string]: string | undefined; +} + +/** + * 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 => { + if (typeof values[key] === 'undefined') { + delete values[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 + ); + } + // Request could fail before the server responds. + throw error; + } + } +} diff --git a/test/test.stscredentials.ts b/test/test.stscredentials.ts new file mode 100644 index 00000000..adb82ff4 --- /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(stsSuccessfulResponse, resp); + 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(stsSuccessfulResponse, resp); + 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(stsSuccessfulResponse, resp); + 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(stsSuccessfulResponse, resp); + 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(stsSuccessfulResponse, resp); + 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(stsSuccessfulResponse, resp); + 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 11b0cb735c47cecef939b0af934747cf9cd29e9a Mon Sep 17 00:00:00 2001 From: Adam Ross Date: Fri, 7 Aug 2020 16:32:13 -0700 Subject: [PATCH 2/4] refactor(samples): idtoken-cloudrun => idtoken-serverless (#1025) --- README.md | 12 ++++++---- samples/README.md | 24 ++++++++++++++++--- ...ens-cloudrun.js => idtokens-serverless.js} | 22 +++++++++-------- samples/test/jwt.test.js | 9 ++++--- 4 files changed, 45 insertions(+), 22 deletions(-) rename samples/{idtokens-cloudrun.js => idtokens-serverless.js} (71%) 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..49711aec 100644 --- a/samples/README.md +++ b/samples/README.md @@ -110,18 +110,36 @@ __Usage:__ +### ID Tokens for Cloud Functions + +Requests a Cloud Functions URL with an ID Token. + +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-serverless.js,samples/README.md) + +__Usage:__ + + +`node idtokens-serverless.js []` + + +----- + + + ### ID Tokens for Cloud Run Requests a Cloud Run URL 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-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-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-serverless.js,samples/README.md) __Usage:__ -`node idtokens-cloudrun.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 = From 0052db2cc19114fca0b3fcb30acc5c137bdac688 Mon Sep 17 00:00:00 2001 From: Bassam Ojeil Date: Mon, 10 Aug 2020 12:06:20 -0700 Subject: [PATCH 3/4] Addresses review comments. Extends `getErrorFromOAuthErrorResponse` to preserve the original error properties. --- src/auth/oauth2common.ts | 27 ++++++++++++++++-- src/auth/stscredentials.ts | 57 ++++++++++++++++++++++++++++++++++--- test/test.oauth2common.ts | 38 +++++++++++++++++++++++++ test/test.stscredentials.ts | 12 ++++---- 4 files changed, 122 insertions(+), 12 deletions(-) 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 index 6beee2e4..64c65e4f 100644 --- a/src/auth/stscredentials.ts +++ b/src/auth/stscredentials.ts @@ -35,15 +35,59 @@ import { * 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; }; } @@ -64,7 +108,8 @@ interface StsRequestOptions { actor_token_type?: string; client_id?: string; client_secret?: string; - [key: string]: string | undefined; + // GCP-specific non-standard field. + options?: string; } /** @@ -136,8 +181,10 @@ export class StsCredentials extends OAuthClientAuthHandler { }; // Remove undefined fields. Object.keys(values).forEach(key => { - if (typeof values[key] === 'undefined') { - delete values[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]; } }); @@ -169,7 +216,9 @@ export class StsCredentials extends OAuthClientAuthHandler { // Translate error to OAuthError. if (error.response) { throw getErrorFromOAuthErrorResponse( - error.response.data as OAuthErrorResponse + error.response.data as OAuthErrorResponse, + // Preserve other fields from the original error. + error ); } // Request could fail before the server responds. 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 index adb82ff4..7efb03ea 100644 --- a/test/test.stscredentials.ts +++ b/test/test.stscredentials.ts @@ -172,7 +172,7 @@ describe('StsCredentials', () => { // Confirm raw GaxiosResponse appended to response. assertGaxiosResponsePresent(resp); delete resp.res; - assert.deepStrictEqual(stsSuccessfulResponse, resp); + assert.deepStrictEqual(resp, stsSuccessfulResponse); scope.done(); }); @@ -191,7 +191,7 @@ describe('StsCredentials', () => { // Confirm raw GaxiosResponse appended to response. assertGaxiosResponsePresent(resp); delete resp.res; - assert.deepStrictEqual(stsSuccessfulResponse, resp); + assert.deepStrictEqual(resp, stsSuccessfulResponse); scope.done(); }); @@ -268,7 +268,7 @@ describe('StsCredentials', () => { // Confirm raw GaxiosResponse appended to response. assertGaxiosResponsePresent(resp); delete resp.res; - assert.deepStrictEqual(stsSuccessfulResponse, resp); + assert.deepStrictEqual(resp, stsSuccessfulResponse); scope.done(); }); @@ -293,7 +293,7 @@ describe('StsCredentials', () => { // Confirm raw GaxiosResponse appended to response. assertGaxiosResponsePresent(resp); delete resp.res; - assert.deepStrictEqual(stsSuccessfulResponse, resp); + assert.deepStrictEqual(resp, stsSuccessfulResponse); scope.done(); }); @@ -349,7 +349,7 @@ describe('StsCredentials', () => { // Confirm raw GaxiosResponse appended to response. assertGaxiosResponsePresent(resp); delete resp.res; - assert.deepStrictEqual(stsSuccessfulResponse, resp); + assert.deepStrictEqual(resp, stsSuccessfulResponse); scope.done(); }); @@ -371,7 +371,7 @@ describe('StsCredentials', () => { // Confirm raw GaxiosResponse appended to response. assertGaxiosResponsePresent(resp); delete resp.res; - assert.deepStrictEqual(stsSuccessfulResponse, resp); + assert.deepStrictEqual(resp, stsSuccessfulResponse); scope.done(); }); From 4df48dff431fd49f0270c7e3509c3c5874d7a9b5 Mon Sep 17 00:00:00 2001 From: Yoshi Automation Bot Date: Mon, 10 Aug 2020 18:42:52 -0700 Subject: [PATCH 4/4] docs: README updated with new sample paths --- samples/README.md | 36 +++++++++--------------------------- synth.metadata | 2 +- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/samples/README.md b/samples/README.md index 49711aec..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,27 +110,28 @@ __Usage:__ -### ID Tokens for Cloud Functions +### ID Tokens for Identity-Aware Proxy (IAP) -Requests a Cloud Functions 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-serverless.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-serverless.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-serverless.js []` +`node idtokens-iap.js ` ----- -### ID Tokens for Cloud Run -Requests a Cloud Run URL with an ID Token. +### ID Tokens for Serverless + +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-serverless.js). @@ -147,25 +148,6 @@ __Usage:__ -### ID Tokens for Identity-Aware Proxy (IAP) - -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-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) - -__Usage:__ - - -`node idtokens-iap.js ` - - ------ - - - - ### Jwt View the [source code](https://github.com/googleapis/google-auth-library-nodejs/blob/master/samples/jwt.js). 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" } }, {