diff --git a/samples/test/externalclient.test.js b/samples/test/externalclient.test.js index 6f128529..57c7bc56 100644 --- a/samples/test/externalclient.test.js +++ b/samples/test/externalclient.test.js @@ -72,11 +72,7 @@ const {assert} = require('chai'); const {describe, it, before, afterEach} = require('mocha'); const fs = require('fs'); const {promisify} = require('util'); -const { - GoogleAuth, - DefaultTransporter, - IdentityPoolClient, -} = require('google-auth-library'); +const {GoogleAuth, IdentityPoolClient, gaxios} = require('google-auth-library'); const os = require('os'); const path = require('path'); const http = require('http'); @@ -158,11 +154,16 @@ const assumeRoleWithWebIdentity = async ( // 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}`; + const transporter = new gaxios.Gaxios(); + + const url = new URL('https://sts.amazonaws.com/'); + url.searchParams.append('Action', 'AssumeRoleWithWebIdentity'); + url.searchParams.append('Version', '2011-06-15'); + url.searchParams.append('DurationSeconds', '3600'); + url.searchParams.append('RoleSessionName', 'nodejs-test'); + url.searchParams.append('RoleArn', awsRoleArn); + url.searchParams.append('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; diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index 1eddeb06..cd7096b4 100644 --- a/src/auth/authclient.ts +++ b/src/auth/authclient.ts @@ -15,10 +15,11 @@ import {EventEmitter} from 'events'; import {Gaxios, GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; -import {DefaultTransporter, Transporter} from '../transporters'; import {Credentials} from './credentials'; import {OriginalAndCamel, originalOrCamelOptions} from '../util'; +import {PRODUCT_NAME, USER_AGENT} from '../shared.cjs'; + /** * Base auth configurations (e.g. from JWT or `.json` files) with conventional * camelCased options. @@ -81,13 +82,17 @@ export interface AuthClientOptions credentials?: Credentials; /** - * A `Gaxios` or `Transporter` instance to use for `AuthClient` requests. + * The {@link Gaxios `Gaxios`} instance used for making requests. + * + * @see {@link AuthClientOptions.useAuthRequestParameters} */ - transporter?: Gaxios | Transporter; + transporter?: Gaxios; /** * Provides default options to the transporter, such as {@link GaxiosOptions.agent `agent`} or * {@link GaxiosOptions.retryConfig `retryConfig`}. + * + * This option is ignored if {@link AuthClientOptions.transporter `gaxios`} has been provided */ transporterOptions?: GaxiosOptions; @@ -103,6 +108,19 @@ export interface AuthClientOptions * on the expiry_date. */ forceRefreshOnFailure?: boolean; + + /** + * Enables/disables the adding of the AuthClient's default interceptor. + * + * @see {@link AuthClientOptions.transporter} + * + * @remarks + * + * Disabling is useful for debugging and experimentation. + * + * @default true + */ + useAuthRequestParameters?: boolean; } /** @@ -183,7 +201,10 @@ export abstract class AuthClient * See {@link https://cloud.google.com/docs/quota Working with quotas} */ quotaProjectId?: string; - transporter: Transporter; + /** + * The {@link Gaxios `Gaxios`} instance used for making requests. + */ + transporter: Gaxios; credentials: Credentials = {}; eagerRefreshThresholdMillis = DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS; forceRefreshOnFailure = false; @@ -202,10 +223,12 @@ export abstract class AuthClient this.universeDomain = options.get('universe_domain') ?? DEFAULT_UNIVERSE; // Shared client options - this.transporter = opts.transporter ?? new DefaultTransporter(); + this.transporter = opts.transporter ?? new Gaxios(opts.transporterOptions); - if (opts.transporterOptions) { - this.transporter.defaults = opts.transporterOptions; + if (options.get('useAuthRequestParameters') !== false) { + this.transporter.interceptors.request.add( + AuthClient.DEFAULT_REQUEST_INTERCEPTOR + ); } if (opts.eagerRefreshThresholdMillis) { @@ -216,29 +239,11 @@ export abstract class AuthClient } /** - * Return the {@link Gaxios `Gaxios`} instance from the {@link AuthClient.transporter}. + * The public request API in which credentials may be added to the request. * - * @expiremental - */ - get gaxios(): Gaxios | null { - if (this.transporter instanceof Gaxios) { - return this.transporter; - } else if (this.transporter instanceof DefaultTransporter) { - return this.transporter.instance; - } else if ( - 'instance' in this.transporter && - this.transporter.instance instanceof Gaxios - ) { - return this.transporter.instance; - } - - return null; - } - - /** - * Provides an alternative Gaxios request implementation with auth credentials + * @param options options for `gaxios` */ - abstract request(opts: GaxiosOptions): GaxiosPromise; + abstract request(options: GaxiosOptions): GaxiosPromise; /** * The main authentication interface. It takes an optional url which when @@ -288,6 +293,31 @@ export abstract class AuthClient return headers; } + static readonly DEFAULT_REQUEST_INTERCEPTOR: Parameters< + Gaxios['interceptors']['request']['add'] + >[0] = { + resolved: async config => { + const headers = config.headers || {}; + + // Set `x-goog-api-client`, if not already set + if (!headers['x-goog-api-client']) { + const nodeVersion = process.version.replace(/^v/, ''); + headers['x-goog-api-client'] = `gl-node/${nodeVersion}`; + } + + // Set `User-Agent` + if (!headers['User-Agent']) { + headers['User-Agent'] = USER_AGENT; + } else if (!headers['User-Agent'].includes(`${PRODUCT_NAME}/`)) { + headers['User-Agent'] = `${headers['User-Agent']} ${USER_AGENT}`; + } + + config.headers = headers; + + return config; + }, + }; + /** * Retry config for Auth-related requests. * @@ -315,3 +345,10 @@ export interface GetAccessTokenResponse { token?: string | null; res?: GaxiosResponse | null; } + +/** + * @deprecated - use the Promise API instead + */ +export interface BodyResponseCallback { + (err: Error | null, res?: GaxiosResponse | null): void; +} diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 5d91a407..2ea85239 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -27,8 +27,8 @@ import { AuthClientOptions, GetAccessTokenResponse, Headers, + BodyResponseCallback, } from './authclient'; -import {BodyResponseCallback, Transporter} from '../transporters'; import * as sts from './stscredentials'; import {ClientAuthentication} from './oauth2common'; import {SnakeToCamelObject, originalOrCamelOptions} from '../util'; @@ -110,10 +110,11 @@ export interface ExternalAccountSupplierContext { * * "urn:ietf:params:oauth:token-type:id_token" */ subjectTokenType: string; - /** The {@link Gaxios} or {@link Transporter} instance from - * the calling external account to use for requests. + /** + * The {@link Gaxios} instance for calling external account + * to use for requests. */ - transporter: Transporter | Gaxios; + transporter: Gaxios; } /** @@ -312,7 +313,10 @@ export abstract class BaseExternalAccountClient extends AuthClient { }; } - this.stsCredential = new sts.StsCredentials(tokenUrl, this.clientAuth); + this.stsCredential = new sts.StsCredentials({ + tokenExchangeEndpoint: tokenUrl, + clientAuthentication: this.clientAuth, + }); this.scopes = opts.get('scopes') || [DEFAULT_OAUTH_SCOPE]; this.cachedAccessToken = null; this.audience = opts.get('audience'); diff --git a/src/auth/defaultawssecuritycredentialssupplier.ts b/src/auth/defaultawssecuritycredentialssupplier.ts index 2c06c134..011064bd 100644 --- a/src/auth/defaultawssecuritycredentialssupplier.ts +++ b/src/auth/defaultawssecuritycredentialssupplier.ts @@ -14,7 +14,6 @@ import {ExternalAccountSupplierContext} from './baseexternalclient'; import {Gaxios, GaxiosOptions} from 'gaxios'; -import {Transporter} from '../transporters'; import {AwsSecurityCredentialsSupplier} from './awsclient'; import {AwsSecurityCredentials} from './awsrequestsigner'; import {Headers} from './authclient'; @@ -183,9 +182,7 @@ export class DefaultAwsSecurityCredentialsSupplier * @param transporter The transporter to use for requests. * @return A promise that resolves with the IMDSv2 Session Token. */ - async #getImdsV2SessionToken( - transporter: Transporter | Gaxios - ): Promise { + async #getImdsV2SessionToken(transporter: Gaxios): Promise { const opts: GaxiosOptions = { ...this.additionalGaxiosOptions, url: this.imdsV2SessionTokenUrl, @@ -205,7 +202,7 @@ export class DefaultAwsSecurityCredentialsSupplier */ async #getAwsRoleName( headers: Headers, - transporter: Transporter | Gaxios + transporter: Gaxios ): Promise { if (!this.securityCredentialsUrl) { throw new Error( @@ -236,7 +233,7 @@ export class DefaultAwsSecurityCredentialsSupplier async #retrieveAwsSecurityCredentials( roleName: string, headers: Headers, - transporter: Transporter | Gaxios + transporter: Gaxios ): Promise { const response = await transporter.request({ ...this.additionalGaxiosOptions, diff --git a/src/auth/downscopedclient.ts b/src/auth/downscopedclient.ts index 9b237bc8..2a8d7a08 100644 --- a/src/auth/downscopedclient.ts +++ b/src/auth/downscopedclient.ts @@ -20,13 +20,13 @@ import { } from 'gaxios'; import * as stream from 'stream'; -import {BodyResponseCallback} from '../transporters'; import {Credentials} from './credentials'; import { AuthClient, AuthClientOptions, GetAccessTokenResponse, Headers, + BodyResponseCallback, } from './authclient'; import * as sts from './stscredentials'; @@ -189,9 +189,9 @@ export class DownscopedClient extends AuthClient { } } - this.stsCredential = new sts.StsCredentials( - `https://sts.${this.universeDomain}/v1/token` - ); + this.stsCredential = new sts.StsCredentials({ + tokenExchangeEndpoint: `https://sts.${this.universeDomain}/v1/token`, + }); this.cachedDownscopedAccessToken = null; } diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index 3cbef1fd..fb837ceb 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {AuthClient, Headers} from './authclient'; +import {AuthClient, Headers, BodyResponseCallback} from './authclient'; import { ClientAuthentication, getErrorFromOAuthErrorResponse, OAuthClientAuthHandler, + OAuthClientAuthHandlerOptions, OAuthErrorResponse, } from './oauth2common'; -import {BodyResponseCallback, Transporter} from '../transporters'; import { GaxiosError, GaxiosOptions, @@ -69,11 +69,21 @@ interface TokenRefreshResponse { res?: GaxiosResponse | null; } +interface ExternalAccountAuthorizedUserHandlerOptions + extends OAuthClientAuthHandlerOptions { + /** + * The URL of the token refresh endpoint. + */ + tokenRefreshEndpoint: string | URL; +} + /** * Handler for token refresh requests sent to the token_url endpoint for external * authorized user credentials. */ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { + #tokenRefreshEndpoint: string | URL; + /** * Initializes an ExternalAccountAuthorizedUserHandler instance. * @param url The URL of the token refresh endpoint. @@ -81,12 +91,10 @@ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { * @param clientAuthentication The client authentication credentials to use * for the refresh request. */ - constructor( - private readonly url: string, - private readonly transporter: Transporter, - clientAuthentication?: ClientAuthentication - ) { - super(clientAuthentication); + constructor(options: ExternalAccountAuthorizedUserHandlerOptions) { + super(options); + + this.#tokenRefreshEndpoint = options.tokenRefreshEndpoint; } /** @@ -114,7 +122,7 @@ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { const opts: GaxiosOptions = { ...ExternalAccountAuthorizedUserHandler.RETRY_CONFIG, - url: this.url, + url: this.#tokenRefreshEndpoint, method: 'POST', headers, data: values.toString(), @@ -169,18 +177,19 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { this.universeDomain = options.universe_domain; } this.refreshToken = options.refresh_token; - const clientAuth = { + const clientAuthentication = { confidentialClientType: 'basic', clientId: options.client_id, clientSecret: options.client_secret, } as ClientAuthentication; this.externalAccountAuthorizedUserHandler = - new ExternalAccountAuthorizedUserHandler( - options.token_url ?? + new ExternalAccountAuthorizedUserHandler({ + tokenRefreshEndpoint: + options.token_url ?? DEFAULT_TOKEN_URL.replace('{universeDomain}', this.universeDomain), - this.transporter, - clientAuth - ); + transporter: this.transporter, + clientAuthentication, + }); this.cachedAccessToken = null; this.quotaProjectId = options.quota_project_id; diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index f74720ce..af9efb29 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -23,13 +23,13 @@ import * as stream from 'stream'; import * as formatEcdsa from 'ecdsa-sig-formatter'; import {createCrypto, JwkCertificate, hasBrowserCrypto} from '../crypto/crypto'; -import {BodyResponseCallback} from '../transporters'; import { AuthClient, AuthClientOptions, GetAccessTokenResponse, Headers, + BodyResponseCallback, } from './authclient'; import {CredentialRequest, Credentials} from './credentials'; import {LoginTicket, TokenPayload} from './loginticket'; diff --git a/src/auth/oauth2common.ts b/src/auth/oauth2common.ts index 19f4fe8e..06c37522 100644 --- a/src/auth/oauth2common.ts +++ b/src/auth/oauth2common.ts @@ -12,10 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GaxiosOptions} from 'gaxios'; +import {Gaxios, GaxiosOptions} from 'gaxios'; import * as querystring from 'querystring'; -import {Crypto, createCrypto} from '../crypto/crypto'; +import {createCrypto} from '../crypto/crypto'; /** List of HTTP methods that accept request bodies. */ const METHODS_SUPPORTING_REQUEST_BODY = ['PUT', 'POST', 'PATCH']; @@ -60,6 +60,18 @@ export interface ClientAuthentication { clientSecret?: string; } +export interface OAuthClientAuthHandlerOptions { + /** + * Defines the client authentication credentials for basic and request-body + * credentials. + */ + clientAuthentication?: ClientAuthentication; + /** + * An optional transporter to use. + */ + transporter?: Gaxios; +} + /** * Abstract class for handling client authentication in OAuth-based * operations. @@ -68,14 +80,22 @@ export interface ClientAuthentication { * request bodies are supported. */ export abstract class OAuthClientAuthHandler { - private crypto: Crypto; + #crypto = createCrypto(); + #clientAuthentication?: ClientAuthentication; + protected transporter: Gaxios; /** * Instantiates an OAuth client authentication handler. - * @param clientAuthentication The client auth credentials. + * @param options The OAuth Client Auth Handler instance options. Passing an `ClientAuthentication` directly is **@DEPRECATED**. */ - constructor(private readonly clientAuthentication?: ClientAuthentication) { - this.crypto = createCrypto(); + constructor(options?: ClientAuthentication | OAuthClientAuthHandlerOptions) { + if (options && 'clientId' in options) { + this.#clientAuthentication = options; + this.transporter = new Gaxios(); + } else { + this.#clientAuthentication = options?.clientAuthentication; + this.transporter = options?.transporter || new Gaxios(); + } } /** @@ -117,11 +137,11 @@ export abstract class OAuthClientAuthHandler { Object.assign(opts.headers, { Authorization: `Bearer ${bearerToken}}`, }); - } else if (this.clientAuthentication?.confidentialClientType === 'basic') { + } 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( + const clientId = this.#clientAuthentication!.clientId; + const clientSecret = this.#clientAuthentication!.clientSecret || ''; + const base64EncodedCreds = this.#crypto.encodeBase64StringUtf8( `${clientId}:${clientSecret}` ); Object.assign(opts.headers, { @@ -138,7 +158,7 @@ export abstract class OAuthClientAuthHandler { * depending on the client authentication mechanism to be used. */ private injectAuthenticatedRequestBody(opts: GaxiosOptions) { - if (this.clientAuthentication?.confidentialClientType === 'request-body') { + if (this.#clientAuthentication?.confidentialClientType === 'request-body') { const method = (opts.method || 'GET').toUpperCase(); // Inject authenticated request body. if (METHODS_SUPPORTING_REQUEST_BODY.indexOf(method) !== -1) { @@ -155,27 +175,27 @@ export abstract class OAuthClientAuthHandler { opts.data = opts.data || ''; const data = querystring.parse(opts.data); Object.assign(data, { - client_id: this.clientAuthentication!.clientId, - client_secret: this.clientAuthentication!.clientSecret || '', + 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 || '', + client_id: this.#clientAuthentication!.clientId, + client_secret: this.#clientAuthentication!.clientSecret || '', }); } else { throw new Error( `${contentType} content-types are not supported with ` + - `${this.clientAuthentication!.confidentialClientType} ` + + `${this.#clientAuthentication!.confidentialClientType} ` + 'client authentication' ); } } else { throw new Error( `${method} HTTP method does not support ` + - `${this.clientAuthentication!.confidentialClientType} ` + + `${this.#clientAuthentication!.confidentialClientType} ` + 'client authentication' ); } diff --git a/src/auth/stscredentials.ts b/src/auth/stscredentials.ts index f5a596ac..72bfceb5 100644 --- a/src/auth/stscredentials.ts +++ b/src/auth/stscredentials.ts @@ -14,12 +14,11 @@ import {GaxiosError, GaxiosOptions, GaxiosResponse} from 'gaxios'; import * as querystring from 'querystring'; - -import {DefaultTransporter, Transporter} from '../transporters'; import {Headers} from './authclient'; import { ClientAuthentication, OAuthClientAuthHandler, + OAuthClientAuthHandlerOptions, OAuthErrorResponse, getErrorFromOAuthErrorResponse, } from './oauth2common'; @@ -126,25 +125,50 @@ export interface StsSuccessfulResponse { res?: GaxiosResponse | null; } +export interface StsCredentialsConstructionOptions + extends OAuthClientAuthHandlerOptions { + /** + * The client authentication credentials if available. + */ + clientAuthentication?: ClientAuthentication; + /** + * The token exchange endpoint. + */ + tokenExchangeEndpoint: string | URL; +} + /** * Implements the OAuth 2.0 token exchange based on * https://tools.ietf.org/html/rfc8693 */ export class StsCredentials extends OAuthClientAuthHandler { - private transporter: Transporter; + readonly #tokenExchangeEndpoint: string | URL; /** * Initializes an STS credentials instance. - * @param tokenExchangeEndpoint The token exchange endpoint. - * @param clientAuthentication The client authentication credentials if - * available. + * + * @param options The STS credentials instance options. Passing an `tokenExchangeEndpoint` directly is **@DEPRECATED**. + * @param clientAuthentication **@DEPRECATED**. Provide a {@link StsCredentialsConstructionOptions `StsCredentialsConstructionOptions`} object in the first parameter instead. */ constructor( - private readonly tokenExchangeEndpoint: string | URL, + options: StsCredentialsConstructionOptions | string | URL = { + tokenExchangeEndpoint: '', + }, + /** + * @deprecated - provide a {@link StsCredentialsConstructionOptions `StsCredentialsConstructionOptions`} object in the first parameter instead + */ clientAuthentication?: ClientAuthentication ) { - super(clientAuthentication); - this.transporter = new DefaultTransporter(); + if (typeof options !== 'object' || options instanceof URL) { + options = { + tokenExchangeEndpoint: options, + clientAuthentication, + }; + } + + super(options); + + this.#tokenExchangeEndpoint = options.tokenExchangeEndpoint; } /** @@ -196,7 +220,7 @@ export class StsCredentials extends OAuthClientAuthHandler { const opts: GaxiosOptions = { ...StsCredentials.RETRY_CONFIG, - url: this.tokenExchangeEndpoint.toString(), + url: this.#tokenExchangeEndpoint.toString(), method: 'POST', headers, data: querystring.stringify( diff --git a/src/index.ts b/src/index.ts index 6652a7b8..5607d8be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -87,7 +87,6 @@ export { ExecutableError, } from './auth/pluggable-auth-client'; export {PassThroughClient} from './auth/passthrough'; -export {DefaultTransporter} from './transporters'; type ALL_EXPORTS = (typeof import('./'))[keyof typeof import('./')]; diff --git a/src/shared.cts b/src/shared.cts new file mode 100644 index 00000000..6ecad171 --- /dev/null +++ b/src/shared.cts @@ -0,0 +1,22 @@ +// Copyright 2023 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. + +const pkg: { + name: string; + version: string; +} = require('../../package.json'); + +const PRODUCT_NAME = 'google-api-nodejs-client'; +const USER_AGENT = `${PRODUCT_NAME}/${pkg.version}`; + +export {pkg, PRODUCT_NAME, USER_AGENT}; diff --git a/src/transporters.ts b/src/transporters.ts deleted file mode 100644 index c60a556d..00000000 --- a/src/transporters.ts +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2019 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 { - Gaxios, - GaxiosError, - GaxiosOptions, - GaxiosPromise, - GaxiosResponse, -} from 'gaxios'; - -const pkg = require('../../package.json'); - -const PRODUCT_NAME = 'google-api-nodejs-client'; - -export interface Transporter { - defaults?: GaxiosOptions; - request(opts: GaxiosOptions): GaxiosPromise; -} - -export interface BodyResponseCallback { - // The `body` object is a truly dynamic type. It must be `any`. - (err: Error | null, res?: GaxiosResponse | null): void; -} - -export interface RequestError extends GaxiosError { - errors: Error[]; -} - -export class DefaultTransporter implements Transporter { - /** - * Default user agent. - */ - static readonly USER_AGENT = `${PRODUCT_NAME}/${pkg.version}`; - - /** - * A configurable, replacable `Gaxios` instance. - */ - instance = new Gaxios(); - - /** - * Configures request options before making a request. - * @param opts GaxiosOptions options. - * @return Configured options. - */ - configure(opts: GaxiosOptions = {}): GaxiosOptions { - opts.headers = opts.headers || {}; - if (typeof window === 'undefined') { - // set transporter user agent if not in browser - const uaValue: string = opts.headers['User-Agent']; - if (!uaValue) { - opts.headers['User-Agent'] = DefaultTransporter.USER_AGENT; - } else if (!uaValue.includes(`${PRODUCT_NAME}/`)) { - opts.headers['User-Agent'] = - `${uaValue} ${DefaultTransporter.USER_AGENT}`; - } - // track google-auth-library-nodejs version: - if (!opts.headers['x-goog-api-client']) { - const nodeVersion = process.version.replace(/^v/, ''); - opts.headers['x-goog-api-client'] = `gl-node/${nodeVersion}`; - } - } - return opts; - } - - /** - * Makes a request using Gaxios with given options. - * @param opts GaxiosOptions options. - * @param callback optional callback that contains GaxiosResponse object. - * @return GaxiosPromise, assuming no callback is passed. - */ - request(opts: GaxiosOptions): GaxiosPromise { - // ensure the user isn't passing in request-style options - opts = this.configure(opts); - return this.instance.request(opts).catch(e => { - throw this.processError(e); - }); - } - - get defaults() { - return this.instance.defaults; - } - - set defaults(opts: GaxiosOptions) { - this.instance.defaults = opts; - } - - /** - * Changes the error to include details from the body. - */ - private processError(e: GaxiosError): RequestError { - const res = e.response; - const err = e as RequestError; - const body = res ? res.data : null; - if (res && body && body.error && res.status !== 200) { - if (typeof body.error === 'string') { - err.message = body.error; - err.status = res.status; - } else if (Array.isArray(body.error.errors)) { - err.message = body.error.errors - .map((err2: Error) => err2.message) - .join('\n'); - err.code = body.error.code; - err.errors = body.error.errors; - } else { - err.message = body.error.message; - err.code = body.error.code; - } - } else if (res && res.status >= 400) { - // Consider all 4xx and 5xx responses errors. - err.message = body; - err.status = res.status; - } - return err; - } -} diff --git a/test/test.authclient.ts b/test/test.authclient.ts index 2faa0f15..2150bb4a 100644 --- a/test/test.authclient.ts +++ b/test/test.authclient.ts @@ -14,8 +14,11 @@ import {strict as assert} from 'assert'; -import {PassThroughClient} from '../src'; +import {Gaxios, GaxiosOptions} from 'gaxios'; + +import {AuthClient, PassThroughClient} from '../src'; import {snakeToCamel} from '../src/util'; +import {PRODUCT_NAME, USER_AGENT} from '../src/shared.cjs'; describe('AuthClient', () => { it('should accept and normalize snake case options to camel case', () => { @@ -38,4 +41,106 @@ describe('AuthClient', () => { assert.equal(authClient[camelCased], value); } }); + + describe('shared auth interceptor', () => { + it('should use the default interceptor', () => { + const gaxios = new Gaxios(); + + new PassThroughClient({transporter: gaxios}); + + assert( + gaxios.interceptors.request.has(AuthClient.DEFAULT_REQUEST_INTERCEPTOR) + ); + }); + + it('should allow disabling of the default interceptor', () => { + const gaxios = new Gaxios(); + const originalInterceptorCount = gaxios.interceptors.request.size; + + const authClient = new PassThroughClient({ + transporter: gaxios, + useAuthRequestParameters: false, + }); + + assert.equal(authClient.transporter, gaxios); + assert.equal( + authClient.transporter.interceptors.request.size, + originalInterceptorCount + ); + }); + + it('should add the default interceptor exactly once between instances', () => { + const gaxios = new Gaxios(); + const originalInterceptorCount = gaxios.interceptors.request.size; + const expectedInterceptorCount = originalInterceptorCount + 1; + + new PassThroughClient({transporter: gaxios}); + new PassThroughClient({transporter: gaxios}); + + assert.equal(gaxios.interceptors.request.size, expectedInterceptorCount); + }); + + describe('User-Agent', () => { + it('should set the header if it does not exist', async () => { + const options: GaxiosOptions = {}; + + await AuthClient.DEFAULT_REQUEST_INTERCEPTOR?.resolved?.(options); + + assert.equal(options.headers?.['User-Agent'], USER_AGENT); + }); + + it('should append to the header if it does exist and does not have the product name', async () => { + const base = 'ABC XYZ'; + const expected = `${base} ${USER_AGENT}`; + const options: GaxiosOptions = { + headers: { + 'User-Agent': base, + }, + }; + + await AuthClient.DEFAULT_REQUEST_INTERCEPTOR?.resolved?.(options); + + assert.equal(options.headers?.['User-Agent'], expected); + }); + + it('should not append to the header if it does exist and does have the product name', async () => { + const expected = `ABC ${PRODUCT_NAME}/XYZ`; + const options: GaxiosOptions = { + headers: { + 'User-Agent': expected, + }, + }; + + await AuthClient.DEFAULT_REQUEST_INTERCEPTOR?.resolved?.(options); + + assert.equal(options.headers?.['User-Agent'], expected); + }); + }); + + describe('x-goog-api-client', () => { + it('should set the header if it does not exist', async () => { + const options: GaxiosOptions = {}; + + await AuthClient.DEFAULT_REQUEST_INTERCEPTOR?.resolved?.(options); + + assert.equal( + options.headers?.['x-goog-api-client'], + `gl-node/${process.version.replace(/^v/, '')}` + ); + }); + + it('should not overwrite an existing header', async () => { + const expected = 'abc'; + const options: GaxiosOptions = { + headers: { + 'x-goog-api-client': expected, + }, + }; + + await AuthClient.DEFAULT_REQUEST_INTERCEPTOR?.resolved?.(options); + + assert.equal(options.headers?.['x-goog-api-client'], expected); + }); + }); + }); }); diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 9d55d89b..ad9743d2 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -629,11 +629,7 @@ describe('BaseExternalAccountClient', () => { ]; const client = new TestExternalAccountClient(options); - await assert.rejects( - client.getProjectId(), - /The caller does not have permission/ - ); - + await assert.rejects(client.getProjectId(), GaxiosError); assert.strictEqual(client.projectId, null); scopes.forEach(scope => scope.done()); }); @@ -1326,10 +1322,7 @@ describe('BaseExternalAccountClient', () => { const client = new TestExternalAccountClient( externalAccountOptionsWithSA ); - await assert.rejects( - client.getAccessToken(), - new RegExp(saErrorResponse.error.message) - ); + await assert.rejects(client.getAccessToken(), GaxiosError); // Next try should succeed. const actualResponse = await client.getAccessToken(); // Confirm raw GaxiosResponse appended to response. @@ -2335,9 +2328,10 @@ describe('BaseExternalAccountClient', () => { data: exampleRequest, responseType: 'json', }, - (err, result) => { - assert.strictEqual(err!.message, errorMessage); - assert.deepStrictEqual(result, (err as GaxiosError)!.response); + err => { + assert(err instanceof GaxiosError); + assert.equal(err.status, 400); + scopes.forEach(scope => scope.done()); done(); } diff --git a/test/test.downscopedclient.ts b/test/test.downscopedclient.ts index d4ae765f..563bb758 100644 --- a/test/test.downscopedclient.ts +++ b/test/test.downscopedclient.ts @@ -1046,9 +1046,10 @@ describe('DownscopedClient', () => { data: exampleRequest, responseType: 'json', }, - (err, result) => { - assert.strictEqual(err!.message, errorMessage); - assert.deepStrictEqual(result, (err as GaxiosError)!.response); + err => { + assert(err instanceof GaxiosError); + assert.equal(err.status, 400); + scopes.forEach(scope => scope.done()); done(); } diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts index 8bdb926e..3f86856d 100644 --- a/test/test.externalaccountauthorizeduserclient.ts +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -658,9 +658,10 @@ describe('ExternalAccountAuthorizedUserClient', () => { data: exampleRequest, responseType: 'json', }, - (err, result) => { - assert.strictEqual(err!.message, errorMessage); - assert.deepStrictEqual(result, (err as GaxiosError)!.response); + err => { + assert(err instanceof GaxiosError); + assert.equal(err.status, 400); + scopes.forEach(scope => scope.done()); done(); } diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index f05d4844..99e6b03c 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -62,6 +62,7 @@ import {stringify} from 'querystring'; import {GoogleAuthExceptionMessages} from '../src/auth/googleauth'; import {IMPERSONATED_ACCOUNT_TYPE} from '../src/auth/impersonated'; import {USER_REFRESH_ACCOUNT_TYPE} from '../src/auth/refreshclient'; +import {GaxiosError} from 'gaxios'; nock.disableNetConnect(); @@ -2250,10 +2251,7 @@ describe('googleauth', () => { const keyFilename = './test/fixtures/external-account-cred.json'; const auth = new GoogleAuth({keyFilename}); - await assert.rejects( - auth.getProjectId(), - /The caller does not have permission/ - ); + await assert.rejects(auth.getProjectId(), GaxiosError); scopes.forEach(s => s.done()); }); diff --git a/test/test.index.ts b/test/test.index.ts index 822eda1c..0c5581d8 100644 --- a/test/test.index.ts +++ b/test/test.index.ts @@ -22,16 +22,9 @@ describe('index', () => { assert.strictEqual(cjs.GoogleAuth, gal.GoogleAuth); }); - it('should publicly export DefaultTransporter', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const cjs = require('../src'); - assert.strictEqual(cjs.DefaultTransporter, gal.DefaultTransporter); - }); - it('should export all the things', () => { assert(gal.CodeChallengeMethod); assert(gal.Compute); - assert(gal.DefaultTransporter); assert(gal.IAMAuth); assert(gal.JWT); assert(gal.JWTAccess); diff --git a/test/test.oauth2.ts b/test/test.oauth2.ts index 7c4d3446..43c27c6d 100644 --- a/test/test.oauth2.ts +++ b/test/test.oauth2.ts @@ -1040,7 +1040,7 @@ describe('oauth2', () => { await client.request({url: 'http://example.com'}); } catch (e) { assert(e instanceof GaxiosError); - assert(e.message.includes(JSON.stringify(reAuthErrorBody))); + assert.deepStrictEqual(e.response?.data, reAuthErrorBody); return; } finally { diff --git a/test/test.transporters.ts b/test/test.transporters.ts deleted file mode 100644 index 055ede56..00000000 --- a/test/test.transporters.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Copyright 2013 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 {GaxiosOptions} from 'gaxios'; -import * as nock from 'nock'; -import {DefaultTransporter, RequestError} from '../src/transporters'; - -describe('transporters', () => { - const savedEnv = process.env; - afterEach(() => { - process.env = savedEnv; - }); - - nock.disableNetConnect(); - - const defaultUserAgentRE = 'google-api-nodejs-client/\\d+.\\d+.\\d+'; - const transporter = new DefaultTransporter(); - - it('should set default adapter to node.js', () => { - const opts = transporter.configure(); - const re = new RegExp(defaultUserAgentRE); - assert(re.test(opts.headers!['User-Agent'])); - }); - - it('should append default client user agent to the existing user agent', () => { - const applicationName = 'MyTestApplication-1.0'; - const opts = transporter.configure({ - headers: {'User-Agent': applicationName}, - url: '', - }); - const re = new RegExp(applicationName + ' ' + defaultUserAgentRE); - assert(re.test(opts.headers!['User-Agent'])); - }); - - it('should not append default client user agent to the existing user agent more than once', () => { - const appName = 'MyTestApplication-1.0 google-api-nodejs-client/foobear'; - const opts = transporter.configure({ - headers: {'User-Agent': appName}, - url: '', - }); - assert.strictEqual(opts.headers!['User-Agent'], appName); - }); - - it('should add x-goog-api-client header if none exists', () => { - const opts = transporter.configure({ - url: '', - }); - assert(/^gl-node\/[.-\w$]+$/.test(opts.headers!['x-goog-api-client'])); - }); - - it('should append to x-goog-api-client header if it exists', () => { - const opts = transporter.configure({ - headers: {'x-goog-api-client': 'gdcl/1.0.0'}, - url: '', - }); - assert(/^gdcl\/[.-\w$]+$/.test(opts.headers!['x-goog-api-client'])); - }); - - // see: https://github.com/googleapis/google-auth-library-nodejs/issues/819 - it('should not append x-goog-api-client header multiple times', () => { - const opts = { - headers: {'x-goog-api-client': 'gdcl/1.0.0'}, - url: '', - }; - let configuredOpts = transporter.configure(opts); - configuredOpts = transporter.configure(opts); - assert( - /^gdcl\/[.-\w$]+$/.test(configuredOpts.headers!['x-goog-api-client']) - ); - }); - - it('should create a single error from multiple response errors', done => { - const firstError = {message: 'Error 1'}; - const secondError = {message: 'Error 2'}; - const url = 'http://example.com'; - const scope = nock(url) - .get('/') - .reply(400, {error: {code: 500, errors: [firstError, secondError]}}); - transporter.request({url}).then( - () => { - scope.done(); - done('Unexpected promise success'); - }, - error => { - scope.done(); - assert.strictEqual(error!.message, 'Error 1\nError 2'); - assert.strictEqual((error as RequestError).code, 500); - assert.strictEqual((error as RequestError).errors.length, 2); - done(); - } - ); - }); - - it('should return an error for a 404 response', done => { - const url = 'http://example.com'; - const scope = nock(url).get('/').reply(404, 'Not found'); - transporter.request({url}).then( - () => { - scope.done(); - done('Unexpected promise success'); - }, - error => { - scope.done(); - assert.strictEqual(error!.message, 'Not found'); - assert.strictEqual((error as RequestError).status, 404); - done(); - } - ); - }); - - it('should support invocation with async/await', async () => { - const url = 'http://example.com'; - const scope = nock(url).get('/').reply(200); - const res = await transporter.request({url}); - scope.done(); - assert.strictEqual(res.status, 200); - }); - - it('should throw if using async/await', async () => { - const url = 'http://example.com'; - const scope = nock(url).get('/').reply(500, '🦃'); - await assert.rejects(transporter.request({url}), /🦃/); - scope.done(); - }); - - it('should work with a callback', done => { - const url = 'http://example.com'; - const scope = nock(url).get('/').reply(200); - transporter.request({url}).then( - res => { - scope.done(); - assert.strictEqual(res!.status, 200); - done(); - }, - error => { - scope.done(); - done(error); - } - ); - }); -});