diff --git a/samples/keepalive.js b/samples/keepalive.js index 2cab0e19..abfabc34 100644 --- a/samples/keepalive.js +++ b/samples/keepalive.js @@ -30,21 +30,26 @@ const https = require('https'); * Acquire a client, and make a request to an API that's enabled by default. */ async function main() { + // create a new agent with keepAlive enabled. + const agent = new https.Agent({keepAlive: true}); + const auth = new GoogleAuth({ scopes: 'https://www.googleapis.com/auth/cloud-platform', + clientOptions: { + transporterOptions: { + agent, + }, + }, }); const client = await auth.getClient(); const projectId = await auth.getProjectId(); const url = `https://dns.googleapis.com/dns/v1/projects/${projectId}`; - // create a new agent with keepAlive enabled - const agent = new https.Agent({keepAlive: true}); - - // use the agent as an Axios config param to make the request - const res = await client.request({url, agent}); + // the agent uses the provided agent. + const res = await client.request({url}); console.log(res.data); - // Re-use the same agent to make the next request over the same connection + // Can also use another agent per-request. const res2 = await client.request({url, agent}); console.log(res2.data); } diff --git a/src/auth/authclient.ts b/src/auth/authclient.ts index f41a1537..12033203 100644 --- a/src/auth/authclient.ts +++ b/src/auth/authclient.ts @@ -13,31 +13,116 @@ // limitations under the License. import {EventEmitter} from 'events'; -import {GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; +import {Gaxios, GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; import {DefaultTransporter, Transporter} from '../transporters'; import {Credentials} from './credentials'; import {Headers} from './oauth2client'; +import {OriginalAndCamel, originalOrCamelOptions} from '../util'; /** - * Defines the root interface for all clients that generate credentials - * for calling Google APIs. All clients should implement this interface. + * Base auth configurations (e.g. from JWT or `.json` files) with conventional + * camelCased options. + * + * @privateRemarks + * + * This interface is purposely not exported so that it can be removed once + * {@link https://github.com/microsoft/TypeScript/issues/50715} has been + * resolved. Then, we can use {@link OriginalAndCamel} to shrink this interface. + * + * Tracking: {@link https://github.com/googleapis/google-auth-library-nodejs/issues/1686} */ -export interface CredentialsClient { +interface AuthJSONOptions { /** * The project ID corresponding to the current credentials if available. */ - projectId?: string | null; + project_id: string | null; + /** + * An alias for {@link AuthJSONOptions.project_id `project_id`}. + */ + projectId: AuthJSONOptions['project_id']; + + /** + * The quota project ID. The quota project can be used by client libraries for the billing purpose. + * See {@link https://cloud.google.com/docs/quota Working with quotas} + */ + quota_project_id: string; + + /** + * An alias for {@link AuthJSONOptions.quota_project_id `quota_project_id`}. + */ + quotaProjectId: AuthJSONOptions['quota_project_id']; + + /** + * The default service domain for a given Cloud universe. + */ + universe_domain: string; + + /** + * An alias for {@link AuthJSONOptions.universe_domain `universe_domain`}. + */ + universeDomain: AuthJSONOptions['universe_domain']; +} + +/** + * Base `AuthClient` configuration. + * + * The camelCased options are aliases of the snake_cased options, supporting both + * JSON API and JS conventions. + */ +export interface AuthClientOptions + extends Partial> { + credentials?: Credentials; + + /** + * A `Gaxios` or `Transporter` instance to use for `AuthClient` requests. + */ + transporter?: Gaxios | Transporter; + + /** + * Provides default options to the transporter, such as {@link GaxiosOptions.agent `agent`} or + * {@link GaxiosOptions.retryConfig `retryConfig`}. + */ + transporterOptions?: GaxiosOptions; /** - * The expiration threshold in milliseconds before forcing token refresh. + * The expiration threshold in milliseconds before forcing token refresh of + * unexpired tokens. */ - eagerRefreshThresholdMillis: number; + eagerRefreshThresholdMillis?: number; /** - * Whether to force refresh on failure when making an authorization request. + * Whether to attempt to refresh tokens on status 401/403 responses + * even if an attempt is made to refresh the token preemptively based + * on the expiry_date. */ - forceRefreshOnFailure: boolean; + forceRefreshOnFailure?: boolean; +} + +/** + * The default cloud universe + * + * @see {@link AuthJSONOptions.universe_domain} + */ +export const DEFAULT_UNIVERSE = 'googleapis.com'; + +/** + * The default {@link AuthClientOptions.eagerRefreshThresholdMillis} + */ +export const DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS = 5 * 60 * 1000; + +/** + * Defines the root interface for all clients that generate credentials + * for calling Google APIs. All clients should implement this interface. + */ +export interface CredentialsClient { + projectId?: AuthClientOptions['projectId']; + eagerRefreshThresholdMillis: NonNullable< + AuthClientOptions['eagerRefreshThresholdMillis'] + >; + forceRefreshOnFailure: NonNullable< + AuthClientOptions['forceRefreshOnFailure'] + >; /** * @return A promise that resolves with the current GCP access token @@ -88,16 +173,42 @@ export abstract class AuthClient extends EventEmitter implements CredentialsClient { + projectId?: string | null; /** * The quota project ID. The quota project can be used by client libraries for the billing purpose. - * See {@link https://cloud.google.com/docs/quota| Working with quotas} + * See {@link https://cloud.google.com/docs/quota Working with quotas} */ quotaProjectId?: string; - transporter: Transporter = new DefaultTransporter(); + transporter: Transporter; credentials: Credentials = {}; - projectId?: string | null; - eagerRefreshThresholdMillis = 5 * 60 * 1000; + eagerRefreshThresholdMillis = DEFAULT_EAGER_REFRESH_THRESHOLD_MILLIS; forceRefreshOnFailure = false; + universeDomain = DEFAULT_UNIVERSE; + + constructor(opts: AuthClientOptions = {}) { + super(); + + const options = originalOrCamelOptions(opts); + + // Shared auth options + this.projectId = options.get('project_id') ?? null; + this.quotaProjectId = options.get('quota_project_id'); + this.credentials = options.get('credentials') ?? {}; + this.universeDomain = options.get('universe_domain') ?? DEFAULT_UNIVERSE; + + // Shared client options + this.transporter = opts.transporter ?? new DefaultTransporter(); + + if (opts.transporterOptions) { + this.transporter.defaults = opts.transporterOptions; + } + + if (opts.eagerRefreshThresholdMillis) { + this.eagerRefreshThresholdMillis = opts.eagerRefreshThresholdMillis; + } + + this.forceRefreshOnFailure = opts.forceRefreshOnFailure ?? false; + } /** * Provides an alternative Gaxios request implementation with auth credentials diff --git a/src/auth/awsclient.ts b/src/auth/awsclient.ts index 4ab39182..bd47c7c4 100644 --- a/src/auth/awsclient.ts +++ b/src/auth/awsclient.ts @@ -19,7 +19,8 @@ import { BaseExternalAccountClient, BaseExternalAccountClientOptions, } from './baseexternalclient'; -import {RefreshOptions, Headers} from './oauth2client'; +import {Headers} from './oauth2client'; +import {AuthClientOptions} from './authclient'; /** * AWS credentials JSON interface. This is used for AWS workloads. @@ -81,11 +82,15 @@ export class AwsClient extends BaseExternalAccountClient { * 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. + * @param additionalOptions **DEPRECATED, all options are available in the + * `options` parameter.** 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) { + constructor( + options: AwsClientOptions, + additionalOptions?: AuthClientOptions + ) { super(options, additionalOptions); this.environmentId = options.credential_source.environment_id; // This is only required if the AWS region is not available in the diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index 8924dedb..6496b143 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -21,11 +21,12 @@ import { import * as stream from 'stream'; import {Credentials} from './credentials'; -import {AuthClient} from './authclient'; +import {AuthClient, AuthClientOptions} from './authclient'; import {BodyResponseCallback} from '../transporters'; -import {GetAccessTokenResponse, Headers, RefreshOptions} from './oauth2client'; +import {GetAccessTokenResponse, Headers} from './oauth2client'; import * as sts from './stscredentials'; import {ClientAuthentication} from './oauth2common'; +import {SnakeToCamelObject, originalOrCamelOptions} from '../util'; /** * The required token exchange grant_type: rfc8693#section-2.1 @@ -63,18 +64,13 @@ const WORKFORCE_AUDIENCE_PATTERN = const pkg = require('../../../package.json'); /** - * The default cloud universe + * For backwards compatibility. */ -export const DEFAULT_UNIVERSE = 'googleapis.com'; +export {DEFAULT_UNIVERSE} from './authclient'; -export interface SharedExternalAccountClientOptions { +export interface SharedExternalAccountClientOptions extends AuthClientOptions { audience: string; token_url: string; - quota_project_id?: string; - /** - * universe domain is the default service domain for a given Cloud universe - */ - universe_domain?: string; } /** @@ -151,51 +147,69 @@ export abstract class BaseExternalAccountClient extends AuthClient { private readonly stsCredential: sts.StsCredentials; private readonly clientAuth?: ClientAuthentication; private readonly workforcePoolUserProject?: string; - public universeDomain = DEFAULT_UNIVERSE; - public projectId: string | null; public projectNumber: string | null; - public readonly eagerRefreshThresholdMillis: number; - public readonly forceRefreshOnFailure: boolean; private readonly configLifetimeRequested: boolean; protected credentialSourceType?: string; /** * Instantiate a BaseExternalAccountClient instance using the provided JSON * object loaded from an external account credentials file. * @param options The external account options object typically loaded - * from the external account JSON credential file. - * @param additionalOptions Optional additional behavior customization - * options. These currently customize expiration threshold time and - * whether to retry on 401/403 API request errors. + * from the external account JSON credential file. The camelCased options + * are aliases for the snake_cased options. + * @param additionalOptions **DEPRECATED, all options are available in the + * `options` parameter.** Optional additional behavior customization options. + * These currently customize expiration threshold time and whether to retry + * on 401/403 API request errors. */ constructor( - options: BaseExternalAccountClientOptions, - additionalOptions?: RefreshOptions + options: + | BaseExternalAccountClientOptions + | SnakeToCamelObject, + additionalOptions?: AuthClientOptions ) { - super(); - if (options.type !== EXTERNAL_ACCOUNT_TYPE) { + super({...options, ...additionalOptions}); + + const opts = originalOrCamelOptions( + options as BaseExternalAccountClientOptions + ); + + if (opts.get('type') !== EXTERNAL_ACCOUNT_TYPE) { throw new Error( `Expected "${EXTERNAL_ACCOUNT_TYPE}" type but ` + `received "${options.type}"` ); } - this.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, - this.clientAuth + + const clientId = opts.get('client_id'); + const clientSecret = opts.get('client_secret'); + const tokenUrl = opts.get('token_url'); + const subjectTokenType = opts.get('subject_token_type'); + const workforcePoolUserProject = opts.get('workforce_pool_user_project'); + const serviceAccountImpersonationUrl = opts.get( + 'service_account_impersonation_url' + ); + const serviceAccountImpersonation = opts.get( + 'service_account_impersonation' ); + const serviceAccountImpersonationLifetime = originalOrCamelOptions( + serviceAccountImpersonation + ).get('token_lifetime_seconds'); + + if (clientId) { + this.clientAuth = { + confidentialClientType: 'basic', + clientId, + clientSecret, + }; + } + + this.stsCredential = new sts.StsCredentials(tokenUrl, this.clientAuth); // Default OAuth scope. This could be overridden via public property. this.scopes = [DEFAULT_OAUTH_SCOPE]; this.cachedAccessToken = null; - this.audience = options.audience; - this.subjectTokenType = options.subject_token_type; - this.quotaProjectId = options.quota_project_id; - this.workforcePoolUserProject = options.workforce_pool_user_project; + this.audience = opts.get('audience'); + this.subjectTokenType = subjectTokenType; + this.workforcePoolUserProject = workforcePoolUserProject; const workforceAudiencePattern = new RegExp(WORKFORCE_AUDIENCE_PATTERN); if ( this.workforcePoolUserProject && @@ -206,33 +220,18 @@ export abstract class BaseExternalAccountClient extends AuthClient { 'credentials.' ); } - this.serviceAccountImpersonationUrl = - options.service_account_impersonation_url; - + this.serviceAccountImpersonationUrl = serviceAccountImpersonationUrl; this.serviceAccountImpersonationLifetime = - options.service_account_impersonation?.token_lifetime_seconds; + serviceAccountImpersonationLifetime; + if (this.serviceAccountImpersonationLifetime) { this.configLifetimeRequested = true; } else { this.configLifetimeRequested = false; this.serviceAccountImpersonationLifetime = DEFAULT_TOKEN_LIFESPAN; } - // As threshold could be zero, - // eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the - // zero value. - if (typeof additionalOptions?.eagerRefreshThresholdMillis !== 'number') { - this.eagerRefreshThresholdMillis = EXPIRATION_TIME_OFFSET; - } else { - this.eagerRefreshThresholdMillis = additionalOptions! - .eagerRefreshThresholdMillis as number; - } - this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; - this.projectId = null; - this.projectNumber = this.getProjectNumber(this.audience); - if (options.universe_domain) { - this.universeDomain = options.universe_domain; - } + this.projectNumber = this.getProjectNumber(this.audience); } /** The service account email to be impersonated, if available. */ diff --git a/src/auth/computeclient.ts b/src/auth/computeclient.ts index 29fabd16..ccecb66a 100644 --- a/src/auth/computeclient.ts +++ b/src/auth/computeclient.ts @@ -16,9 +16,13 @@ import {GaxiosError} from 'gaxios'; import * as gcpMetadata from 'gcp-metadata'; import {CredentialRequest, Credentials} from './credentials'; -import {GetTokenResponse, OAuth2Client, RefreshOptions} from './oauth2client'; +import { + GetTokenResponse, + OAuth2Client, + OAuth2ClientOptions, +} from './oauth2client'; -export interface ComputeOptions extends RefreshOptions { +export interface ComputeOptions extends OAuth2ClientOptions { /** * The service account email to use, or 'default'. A Compute Engine instance * may have multiple service accounts. diff --git a/src/auth/downscopedclient.ts b/src/auth/downscopedclient.ts index 59305d27..92238b40 100644 --- a/src/auth/downscopedclient.ts +++ b/src/auth/downscopedclient.ts @@ -22,9 +22,9 @@ import * as stream from 'stream'; import {BodyResponseCallback} from '../transporters'; import {Credentials} from './credentials'; -import {AuthClient} from './authclient'; +import {AuthClient, AuthClientOptions} from './authclient'; -import {GetAccessTokenResponse, Headers, RefreshOptions} from './oauth2client'; +import {GetAccessTokenResponse, Headers} from './oauth2client'; import * as sts from './stscredentials'; /** @@ -107,8 +107,6 @@ interface AvailabilityCondition { export class DownscopedClient extends AuthClient { private cachedDownscopedAccessToken: CredentialsWithResponse | null; private readonly stsCredential: sts.StsCredentials; - public readonly eagerRefreshThresholdMillis: number; - public readonly forceRefreshOnFailure: boolean; /** * Instantiates a downscoped client object using the provided source @@ -125,19 +123,18 @@ export class DownscopedClient extends AuthClient { * on the resource that the rule applies to, the upper bound of the * permissions that are available on that resource and an optional * condition to further restrict permissions. - * @param additionalOptions Optional additional behavior customization - * options. These currently customize expiration threshold time and - * whether to retry on 401/403 API request errors. - * @param quotaProjectId Optional quota project id for setting up in the - * x-goog-user-project header. + * @param additionalOptions **DEPRECATED, set this in the provided `authClient`.** + * Optional additional behavior customization options. + * @param quotaProjectId **DEPRECATED, set this in the provided `authClient`.** + * Optional quota project id for setting up in the x-goog-user-project header. */ constructor( private readonly authClient: AuthClient, private readonly credentialAccessBoundary: CredentialAccessBoundary, - additionalOptions?: RefreshOptions, + additionalOptions?: AuthClientOptions, quotaProjectId?: string ) { - super(); + super({...additionalOptions, quotaProjectId}); // Check 1-10 Access Boundary Rules are defined within Credential Access // Boundary. if ( @@ -167,17 +164,6 @@ export class DownscopedClient extends AuthClient { this.stsCredential = new sts.StsCredentials(STS_ACCESS_TOKEN_URL); this.cachedDownscopedAccessToken = null; - // As threshold could be zero, - // eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the - // zero value. - if (typeof additionalOptions?.eagerRefreshThresholdMillis !== 'number') { - this.eagerRefreshThresholdMillis = EXPIRATION_TIME_OFFSET; - } else { - this.eagerRefreshThresholdMillis = additionalOptions! - .eagerRefreshThresholdMillis as number; - } - this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; - this.quotaProjectId = quotaProjectId; } /** diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index e6eca331..c9534440 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {AuthClient} from './authclient'; -import {Headers, RefreshOptions} from './oauth2client'; +import {AuthClient, AuthClientOptions} from './authclient'; +import {Headers} from './oauth2client'; import { ClientAuthentication, getErrorFromOAuthErrorResponse, @@ -30,7 +30,6 @@ import { import {Credentials} from './credentials'; import * as stream from 'stream'; import { - DEFAULT_UNIVERSE, EXPIRATION_TIME_OFFSET, SharedExternalAccountClientOptions, } from './baseexternalclient'; @@ -155,7 +154,6 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { private cachedAccessToken: CredentialsWithResponse | null; private readonly externalAccountAuthorizedUserHandler: ExternalAccountAuthorizedUserHandler; private refreshToken: string; - public universeDomain = DEFAULT_UNIVERSE; /** * Instantiates an ExternalAccountAuthorizedUserClient instances using the @@ -163,15 +161,16 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { * An error is throws if the credential is not valid. * @param options The external account authorized user option object typically * from the external accoutn authorized user 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. + * @param additionalOptions **DEPRECATED, all options are available in the + * `options` parameter.** Optional additional behavior customization options. + * These currently customize expiration threshold time and whether to retry + * on 401/403 API request errors. */ constructor( options: ExternalAccountAuthorizedUserClientOptions, - additionalOptions?: RefreshOptions + additionalOptions?: AuthClientOptions ) { - super(); + super({...options, ...additionalOptions}); this.refreshToken = options.refresh_token; const clientAuth = { confidentialClientType: 'basic', diff --git a/src/auth/externalclient.ts b/src/auth/externalclient.ts index b9c792ff..95b97da3 100644 --- a/src/auth/externalclient.ts +++ b/src/auth/externalclient.ts @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {RefreshOptions} from './oauth2client'; import { BaseExternalAccountClient, // This is the identifier in the JSON config for the type of credential. @@ -33,6 +32,7 @@ import { PluggableAuthClient, PluggableAuthClientOptions, } from './pluggable-auth-client'; +import {AuthClientOptions} from './authclient'; export type ExternalAccountClientOptions = | IdentityPoolClientOptions @@ -60,15 +60,16 @@ export class ExternalAccountClient { * 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. + * @param additionalOptions **DEPRECATED, all options are available in the + * `options` parameter.** Optional additional behavior customization options. + * These currently customize expiration threshold time and whether to retry + * on 401/403 API request errors. * @return A BaseExternalAccountClient instance or null if the options * provided do not correspond to an external account credential. */ static fromJSON( options: ExternalAccountClientOptions, - additionalOptions?: RefreshOptions + additionalOptions?: AuthClientOptions ): BaseExternalAccountClient | null { if (options && options.type === EXTERNAL_ACCOUNT_TYPE) { if ((options as AwsClientOptions).credential_source?.environment_id) { diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 7167c416..647090c1 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -28,7 +28,7 @@ import {CredentialBody, ImpersonatedJWTInput, JWTInput} from './credentials'; import {IdTokenClient} from './idtokenclient'; import {GCPEnv, getEnv} from './envDetect'; import {JWT, JWTOptions} from './jwtclient'; -import {Headers, OAuth2ClientOptions, RefreshOptions} from './oauth2client'; +import {Headers, OAuth2ClientOptions} from './oauth2client'; import { UserRefreshClient, UserRefreshClientOptions, @@ -47,7 +47,7 @@ import { EXTERNAL_ACCOUNT_TYPE, BaseExternalAccountClient, } from './baseexternalclient'; -import {AuthClient} from './authclient'; +import {AuthClient, AuthClientOptions} from './authclient'; import { EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, ExternalAccountAuthorizedUserClient, @@ -166,7 +166,7 @@ export class GoogleAuth { defaultScopes?: string | string[]; private keyFilename?: string; private scopes?: string | string[]; - private clientOptions?: RefreshOptions; + private clientOptions?: AuthClientOptions; /** * Export DefaultTransporter as a static property of the class. @@ -302,13 +302,16 @@ export class GoogleAuth { */ getApplicationDefault(): Promise; getApplicationDefault(callback: ADCCallback): void; - getApplicationDefault(options: RefreshOptions): Promise; - getApplicationDefault(options: RefreshOptions, callback: ADCCallback): void; + getApplicationDefault(options: AuthClientOptions): Promise; getApplicationDefault( - optionsOrCallback: ADCCallback | RefreshOptions = {}, + options: AuthClientOptions, + callback: ADCCallback + ): void; + getApplicationDefault( + optionsOrCallback: ADCCallback | AuthClientOptions = {}, callback?: ADCCallback ): void | Promise { - let options: RefreshOptions | undefined; + let options: AuthClientOptions | undefined; if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; } else { @@ -325,7 +328,7 @@ export class GoogleAuth { } private async getApplicationDefaultAsync( - options: RefreshOptions = {} + options: AuthClientOptions = {} ): Promise { // If we've already got a cached credential, return it. // This will also preserve one's configured quota project, in case they @@ -432,7 +435,7 @@ export class GoogleAuth { * @api private */ async _tryGetApplicationCredentialsFromEnvironmentVariable( - options?: RefreshOptions + options?: AuthClientOptions ): Promise { const credentialsPath = process.env['GOOGLE_APPLICATION_CREDENTIALS'] || @@ -460,7 +463,7 @@ export class GoogleAuth { * @api private */ async _tryGetApplicationCredentialsFromWellKnownFile( - options?: RefreshOptions + options?: AuthClientOptions ): Promise { // First, figure out the location of the file, depending upon the OS type. let location = null; @@ -505,7 +508,7 @@ export class GoogleAuth { */ async _getApplicationCredentialsFromFilePath( filePath: string, - options: RefreshOptions = {} + options: AuthClientOptions = {} ): Promise { // Make sure the path looks like a string. if (!filePath || filePath.length === 0) { @@ -609,11 +612,10 @@ export class GoogleAuth { */ fromJSON( json: JWTInput | ImpersonatedJWTInput, - options: RefreshOptions = {} + options: AuthClientOptions = {} ): JSONClient { let client: JSONClient; - options = options || {}; if (json.type === USER_REFRESH_ACCOUNT_TYPE) { client = new UserRefreshClient(options); client.fromJSON(json); @@ -647,8 +649,8 @@ export class GoogleAuth { * @returns JWT or UserRefresh Client with data */ private _cacheClientFromJSON( - json: JWTInput, - options?: RefreshOptions + json: JWTInput | ImpersonatedJWTInput, + options?: AuthClientOptions ): JSONClient { const client = this.fromJSON(json, options); @@ -667,19 +669,19 @@ export class GoogleAuth { fromStream(inputStream: stream.Readable, callback: CredentialCallback): void; fromStream( inputStream: stream.Readable, - options: RefreshOptions + options: AuthClientOptions ): Promise; fromStream( inputStream: stream.Readable, - options: RefreshOptions, + options: AuthClientOptions, callback: CredentialCallback ): void; fromStream( inputStream: stream.Readable, - optionsOrCallback: RefreshOptions | CredentialCallback = {}, + optionsOrCallback: AuthClientOptions | CredentialCallback = {}, callback?: CredentialCallback ): Promise | void { - let options: RefreshOptions = {}; + let options: AuthClientOptions = {}; if (typeof optionsOrCallback === 'function') { callback = optionsOrCallback; } else { @@ -697,7 +699,7 @@ export class GoogleAuth { private fromStreamAsync( inputStream: stream.Readable, - options?: RefreshOptions + options?: AuthClientOptions ): Promise { return new Promise((resolve, reject) => { if (!inputStream) { @@ -741,7 +743,7 @@ export class GoogleAuth { * @param options An optional options object. * @returns A JWT loaded from the key */ - fromAPIKey(apiKey: string, options?: RefreshOptions): JWT { + fromAPIKey(apiKey: string, options?: AuthClientOptions): JWT { options = options || {}; const client = new JWT(options); client.fromAPIKey(apiKey); diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 06c1e9b5..2ed66b11 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -20,7 +20,8 @@ import { BaseExternalAccountClient, BaseExternalAccountClientOptions, } from './baseexternalclient'; -import {RefreshOptions} from './oauth2client'; +import {AuthClientOptions} from './authclient'; +import {SnakeToCamelObject, originalOrCamelOptions} from '../util'; // fs.readfile is undefined in browser karma tests causing // `npm run browser-test` to fail as test.oauth2.ts imports this file via @@ -73,19 +74,28 @@ export class IdentityPoolClient extends BaseExternalAccountClient { * url-sourced credential or a workforce pool user project is provided * with a non workforce audience. * @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. + * from the external account JSON credential file. The camelCased options + * are aliases for the snake_cased options. + * @param additionalOptions **DEPRECATED, all options are available in the + * `options` parameter.** 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 + options: + | IdentityPoolClientOptions + | SnakeToCamelObject, + additionalOptions?: AuthClientOptions ) { super(options, additionalOptions); - this.file = options.credential_source.file; - this.url = options.credential_source.url; - this.headers = options.credential_source.headers; + + const opts = originalOrCamelOptions(options as IdentityPoolClientOptions); + const credentialSource = opts.get('credential_source'); + const credentialSourceOpts = originalOrCamelOptions(credentialSource); + + this.file = credentialSourceOpts.get('file'); + this.url = credentialSourceOpts.get('url'); + this.headers = credentialSourceOpts.get('headers'); if (this.file && this.url) { throw new Error( 'No valid Identity Pool "credential_source" provided, must be either file or url.' @@ -99,10 +109,17 @@ export class IdentityPoolClient extends BaseExternalAccountClient { 'No valid Identity Pool "credential_source" provided, must be either file or url.' ); } + + const formatOpts = originalOrCamelOptions( + credentialSourceOpts.get('format') + ); + // Text is the default format type. - this.formatType = options.credential_source.format?.type || 'text'; - this.formatSubjectTokenFieldName = - options.credential_source.format?.subject_token_field_name; + this.formatType = formatOpts.get('type') || 'text'; + this.formatSubjectTokenFieldName = formatOpts.get( + 'subject_token_field_name' + ); + if (this.formatType !== 'json' && this.formatType !== 'text') { throw new Error(`Invalid credential_source format "${this.formatType}"`); } diff --git a/src/auth/idtokenclient.ts b/src/auth/idtokenclient.ts index 38a72278..03e186ac 100644 --- a/src/auth/idtokenclient.ts +++ b/src/auth/idtokenclient.ts @@ -13,9 +13,14 @@ // limitations under the License. import {Credentials} from './credentials'; -import {Headers, OAuth2Client, RequestMetadataResponse} from './oauth2client'; +import { + Headers, + OAuth2Client, + OAuth2ClientOptions, + RequestMetadataResponse, +} from './oauth2client'; -export interface IdTokenOptions { +export interface IdTokenOptions extends OAuth2ClientOptions { /** * The client to make the request to fetch an ID token. */ @@ -41,7 +46,7 @@ export class IdTokenClient extends OAuth2Client { * See: https://developers.google.com/compute/docs/authentication */ constructor(options: IdTokenOptions) { - super(); + super(options); this.targetAudience = options.targetAudience; this.idTokenProvider = options.idTokenProvider; } diff --git a/src/auth/impersonated.ts b/src/auth/impersonated.ts index b2cb6994..24debd81 100644 --- a/src/auth/impersonated.ts +++ b/src/auth/impersonated.ts @@ -14,12 +14,16 @@ * limitations under the License. */ -import {GetTokenResponse, OAuth2Client, RefreshOptions} from './oauth2client'; +import { + GetTokenResponse, + OAuth2Client, + OAuth2ClientOptions, +} from './oauth2client'; import {AuthClient} from './authclient'; import {IdTokenProvider} from './idtokenclient'; import {GaxiosError} from 'gaxios'; -export interface ImpersonatedOptions extends RefreshOptions { +export interface ImpersonatedOptions extends OAuth2ClientOptions { /** * Client used to perform exchange for impersonated client. */ @@ -108,6 +112,8 @@ export class Impersonated extends OAuth2Client implements IdTokenProvider { */ constructor(options: ImpersonatedOptions = {}) { super(options); + // Start with an expired refresh token, which will automatically be + // refreshed before the first API call is made. this.credentials = { expiry_date: 1, refresh_token: 'impersonated-placeholder', diff --git a/src/auth/jwtclient.ts b/src/auth/jwtclient.ts index 0d851303..d25f4146 100644 --- a/src/auth/jwtclient.ts +++ b/src/auth/jwtclient.ts @@ -21,11 +21,11 @@ import {JWTAccess} from './jwtaccess'; import { GetTokenResponse, OAuth2Client, - RefreshOptions, + OAuth2ClientOptions, RequestMetadataResponse, } from './oauth2client'; -export interface JWTOptions extends RefreshOptions { +export interface JWTOptions extends OAuth2ClientOptions { email?: string; keyFile?: string; key?: string; @@ -83,10 +83,7 @@ export class JWT extends OAuth2Client implements IdTokenProvider { optionsOrEmail && typeof optionsOrEmail === 'object' ? optionsOrEmail : {email: optionsOrEmail, keyFile, key, keyId, scopes, subject}; - super({ - eagerRefreshThresholdMillis: opts.eagerRefreshThresholdMillis, - forceRefreshOnFailure: opts.forceRefreshOnFailure, - }); + super(opts); this.email = opts.email; this.keyFile = opts.keyFile; this.key = opts.key; @@ -94,6 +91,8 @@ export class JWT extends OAuth2Client implements IdTokenProvider { this.scopes = opts.scopes; this.subject = opts.subject; this.additionalClaims = opts.additionalClaims; + // Start with an expired refresh token, which will automatically be + // refreshed before the first API call is made. this.credentials = {refresh_token: 'jwt-placeholder', expiry_date: 1}; } @@ -103,15 +102,10 @@ export class JWT extends OAuth2Client implements IdTokenProvider { * @return The cloned instance. */ createScoped(scopes?: string | string[]) { - return new JWT({ - email: this.email, - keyFile: this.keyFile, - key: this.key, - keyId: this.keyId, - scopes, - subject: this.subject, - additionalClaims: this.additionalClaims, - }); + const jwt = new JWT(this as {} as JWTOptions); + jwt.scopes = scopes; + + return jwt; } /** diff --git a/src/auth/oauth2client.ts b/src/auth/oauth2client.ts index fc0fddc7..c5ecf5b2 100644 --- a/src/auth/oauth2client.ts +++ b/src/auth/oauth2client.ts @@ -25,7 +25,7 @@ import * as formatEcdsa from 'ecdsa-sig-formatter'; import {createCrypto, JwkCertificate, hasBrowserCrypto} from '../crypto/crypto'; import {BodyResponseCallback} from '../transporters'; -import {AuthClient} from './authclient'; +import {AuthClient, AuthClientOptions} from './authclient'; import {CredentialRequest, Credentials} from './credentials'; import {LoginTicket, TokenPayload} from './loginticket'; /** @@ -404,26 +404,18 @@ export interface VerifyIdTokenOptions { maxExpiry?: number; } -export interface RefreshOptions { - // Eagerly refresh unexpired tokens when they are within this many - // milliseconds from expiring". - // Defaults to a value of 300000 (5 minutes). - eagerRefreshThresholdMillis?: number; - - // Whether to attempt to lazily refresh tokens on 401/403 responses - // even if an attempt is made to refresh the token preemptively based - // on the expiry_date. - // Defaults to false. - forceRefreshOnFailure?: boolean; -} - -export interface OAuth2ClientOptions extends RefreshOptions { +export interface OAuth2ClientOptions extends AuthClientOptions { clientId?: string; clientSecret?: string; redirectUri?: string; - credentials?: Credentials; } +// Re-exporting here for backwards compatibility +export type RefreshOptions = Pick< + AuthClientOptions, + 'eagerRefreshThresholdMillis' | 'forceRefreshOnFailure' +>; + export class OAuth2Client extends AuthClient { private redirectUri?: string; private certificateCache: Certificates = {}; @@ -439,12 +431,6 @@ export class OAuth2Client extends AuthClient { apiKey?: string; - projectId?: string; - - eagerRefreshThresholdMillis: number; - - forceRefreshOnFailure: boolean; - refreshHandler?: GetRefreshHandlerCallback; /** @@ -464,18 +450,16 @@ export class OAuth2Client extends AuthClient { clientSecret?: string, redirectUri?: string ) { - super(); const opts = optionsOrClientId && typeof optionsOrClientId === 'object' ? optionsOrClientId : {clientId: optionsOrClientId, clientSecret, redirectUri}; + + super(opts); + this._clientId = opts.clientId; this._clientSecret = opts.clientSecret; this.redirectUri = opts.redirectUri; - this.eagerRefreshThresholdMillis = - opts.eagerRefreshThresholdMillis || 5 * 60 * 1000; - this.forceRefreshOnFailure = !!opts.forceRefreshOnFailure; - this.credentials = opts.credentials || {}; } protected static readonly GOOGLE_TOKEN_INFO_URL = diff --git a/src/auth/pluggable-auth-client.ts b/src/auth/pluggable-auth-client.ts index ad31c78a..ad9a933f 100644 --- a/src/auth/pluggable-auth-client.ts +++ b/src/auth/pluggable-auth-client.ts @@ -16,12 +16,12 @@ import { BaseExternalAccountClient, BaseExternalAccountClientOptions, } from './baseexternalclient'; -import {RefreshOptions} from './oauth2client'; import { ExecutableResponse, InvalidExpirationTimeFieldError, } from './executable-response'; import {PluggableAuthHandler} from './pluggable-auth-handler'; +import {AuthClientOptions} from './authclient'; /** * Defines the credential source portion of the configuration for PluggableAuthClient. @@ -189,13 +189,14 @@ export class PluggableAuthClient extends BaseExternalAccountClient { * An error is thrown if the credential is not a valid pluggable auth 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. + * @param additionalOptions **DEPRECATED, all options are available in the + * `options` parameter.** Optional additional behavior customization options. + * These currently customize expiration threshold time and whether to retry + * on 401/403 API request errors. */ constructor( options: PluggableAuthClientOptions, - additionalOptions?: RefreshOptions + additionalOptions?: AuthClientOptions ) { super(options, additionalOptions); if (!options.credential_source.executable) { diff --git a/src/auth/refreshclient.ts b/src/auth/refreshclient.ts index e7ca73b3..816be6e5 100644 --- a/src/auth/refreshclient.ts +++ b/src/auth/refreshclient.ts @@ -14,11 +14,15 @@ import * as stream from 'stream'; import {JWTInput} from './credentials'; -import {GetTokenResponse, OAuth2Client, RefreshOptions} from './oauth2client'; +import { + GetTokenResponse, + OAuth2Client, + OAuth2ClientOptions, +} from './oauth2client'; export const USER_REFRESH_ACCOUNT_TYPE = 'authorized_user'; -export interface UserRefreshClientOptions extends RefreshOptions { +export interface UserRefreshClientOptions extends OAuth2ClientOptions { clientId?: string; clientSecret?: string; refreshToken?: string; @@ -57,12 +61,7 @@ export class UserRefreshClient extends OAuth2Client { eagerRefreshThresholdMillis, forceRefreshOnFailure, }; - super({ - clientId: opts.clientId, - clientSecret: opts.clientSecret, - eagerRefreshThresholdMillis: opts.eagerRefreshThresholdMillis, - forceRefreshOnFailure: opts.forceRefreshOnFailure, - }); + super(opts); this._refreshToken = opts.refreshToken; this.credentials.refresh_token = opts.refreshToken; } diff --git a/src/transporters.ts b/src/transporters.ts index 26c1dab2..d4a298f0 100644 --- a/src/transporters.ts +++ b/src/transporters.ts @@ -13,11 +13,11 @@ // limitations under the License. import { + Gaxios, GaxiosError, GaxiosOptions, GaxiosPromise, GaxiosResponse, - request, } from 'gaxios'; import {validate} from './options'; @@ -27,6 +27,7 @@ const pkg = require('../../package.json'); const PRODUCT_NAME = 'google-api-nodejs-client'; export interface Transporter { + defaults?: GaxiosOptions; request(opts: GaxiosOptions): GaxiosPromise; } @@ -45,6 +46,11 @@ export class DefaultTransporter implements Transporter { */ 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. @@ -81,11 +87,19 @@ export class DefaultTransporter implements Transporter { // ensure the user isn't passing in request-style options opts = this.configure(opts); validate(opts); - return request(opts).catch(e => { + 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. */ diff --git a/src/util.ts b/src/util.ts index a9cd68b3..ed4b92de 100644 --- a/src/util.ts +++ b/src/util.ts @@ -12,6 +12,146 @@ // See the License for the specific language governing permissions and // limitations under the License. +/** + * A utility for converting snake_case to camelCase. + * + * For, for example `my_snake_string` becomes `mySnakeString`. + * + * @internal + */ +export type SnakeToCamel = S extends `${infer FirstWord}_${infer Remainder}` + ? `${FirstWord}${Capitalize>}` + : S; + +/** + * A utility for converting an type's keys from snake_case + * to camelCase, if the keys are strings. + * + * For example: + * + * ```ts + * { + * my_snake_string: boolean; + * myCamelString: string; + * my_snake_obj: { + * my_snake_obj_string: string; + * }; + * } + * ``` + * + * becomes: + * + * ```ts + * { + * mySnakeString: boolean; + * myCamelString: string; + * mySnakeObj: { + * mySnakeObjString: string; + * } + * } + * ``` + * + * @remarks + * + * The generated documentation for the camelCase'd properties won't be available + * until {@link https://github.com/microsoft/TypeScript/issues/50715} has been + * resolved. + * + * @internal + */ +export type SnakeToCamelObject = { + [K in keyof T as SnakeToCamel]: T[K] extends {} + ? SnakeToCamelObject + : T[K]; +}; + +/** + * A utility for adding camelCase versions of a type's snake_case keys, if the + * keys are strings, preserving any existing keys. + * + * For example: + * + * ```ts + * { + * my_snake_boolean: boolean; + * myCamelString: string; + * my_snake_obj: { + * my_snake_obj_string: string; + * }; + * } + * ``` + * + * becomes: + * + * ```ts + * { + * my_snake_boolean: boolean; + * mySnakeBoolean: boolean; + * myCamelString: string; + * my_snake_obj: { + * my_snake_obj_string: string; + * }; + * mySnakeObj: { + * mySnakeObjString: string; + * } + * } + * ``` + * @remarks + * + * The generated documentation for the camelCase'd properties won't be available + * until {@link https://github.com/microsoft/TypeScript/issues/50715} has been + * resolved. + * + * Tracking: {@link https://github.com/googleapis/google-auth-library-nodejs/issues/1686} + * + * @internal + */ +export type OriginalAndCamel = { + [K in keyof T as K | SnakeToCamel]: T[K] extends {} + ? OriginalAndCamel + : T[K]; +}; + +/** + * Returns the camel case of a provided string. + * + * @remarks + * + * Match any `_` and not `_` pair, then return the uppercase of the not `_` + * character. + * + * @internal + * + * @param str the string to convert + * @returns the camelCase'd string + */ +export function snakeToCamel(str: T): SnakeToCamel { + return str.replace(/([_][^_])/g, match => + match.slice(1).toUpperCase() + ) as SnakeToCamel; +} + +/** + * Get the value of `obj[key]` or `obj[camelCaseKey]`, with a preference + * for original, non-camelCase key. + * + * @param obj object to lookup a value in + * @returns a `get` function for getting `obj[key || snakeKey]`, if available + */ +export function originalOrCamelOptions(obj?: T) { + /** + * + * @param key an index of object, preferably snake_case + * @returns the value `obj[key || snakeKey]`, if available + */ + function get & string>(key: K) { + const o = (obj || {}) as OriginalAndCamel; + return o[key] ?? o[snakeToCamel(key) as K]; + } + + return {get}; +} + export interface LRUCacheOptions { /** * The maximum number of items to cache. diff --git a/system-test/fixtures/kitchen/package.json b/system-test/fixtures/kitchen/package.json index 79308005..2c149633 100644 --- a/system-test/fixtures/kitchen/package.json +++ b/system-test/fixtures/kitchen/package.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@types/node": "^16.11.3", - "typescript": "^3.0.0", + "typescript": "^5.0.0", "gts": "^5.0.0", "null-loader": "^4.0.0", "ts-loader": "^8.0.0", diff --git a/test/test.authclient.ts b/test/test.authclient.ts new file mode 100644 index 00000000..786d4d04 --- /dev/null +++ b/test/test.authclient.ts @@ -0,0 +1,60 @@ +// 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. + +import {strict as assert} from 'assert'; + +import {GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; +import {AuthClient} from '../src'; +import {Headers} from '../src/auth/oauth2client'; +import {snakeToCamel} from '../src/util'; + +describe('AuthClient', () => { + class TestAuthClient extends AuthClient { + request(opts: GaxiosOptions): GaxiosPromise { + throw new Error('Method not implemented.'); + } + + getRequestHeaders(url?: string | undefined): Promise { + throw new Error('Method not implemented.'); + } + + getAccessToken(): Promise<{ + token?: string | null | undefined; + res?: GaxiosResponse | null | undefined; + }> { + throw new Error('Method not implemented.'); + } + } + + it('should accept and normalize snake case options to camel case', () => { + const expected = { + project_id: 'my-projectId', + quota_project_id: 'my-quota-project-id', + credentials: {}, + universe_domain: 'my-universe-domain', + }; + + for (const [key, value] of Object.entries(expected)) { + const camelCased = snakeToCamel(key) as keyof typeof authClient; + + // assert snake cased input + let authClient = new TestAuthClient({[key]: value}); + assert.equal(authClient[camelCased], value); + + // assert camel cased input + authClient = new TestAuthClient({[camelCased]: value}); + assert.equal(authClient[camelCased], value); + } + }); +}); diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 9d57c74b..96ea57ce 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -40,7 +40,7 @@ import { mockStsTokenExchange, getExpectedExternalAccountMetricsHeaderValue, } from './externalclienthelper'; -import {RefreshOptions} from '../src'; +import {AuthClientOptions} from '../src/auth/authclient'; nock.disableNetConnect(); @@ -55,7 +55,7 @@ class TestExternalAccountClient extends BaseExternalAccountClient { constructor( options: BaseExternalAccountClientOptions, - additionalOptions?: RefreshOptions + additionalOptions?: Partial ) { super(options, additionalOptions); this.credentialSourceType = 'test'; diff --git a/test/test.util.ts b/test/test.util.ts index 6871c621..6f584c9c 100644 --- a/test/test.util.ts +++ b/test/test.util.ts @@ -13,10 +13,21 @@ // limitations under the License. import {strict as assert} from 'assert'; +import * as sinon from 'sinon'; import {LRUCache} from '../src/util'; describe('util', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('LRUCache', () => { it('should set and get a cached item', () => { const expected = 'value'; @@ -50,18 +61,23 @@ describe('util', () => { it('should evict items older than a supplied `maxAge`', async () => { const maxAge = 50; + sandbox.clock = sinon.useFakeTimers(); + const lru = new LRUCache({capacity: 5, maxAge}); lru.set('first', 1); lru.set('second', 2); - await new Promise(res => setTimeout(res, maxAge + 1)); + // back to the future 🏎️ + sandbox.clock.tick(maxAge + 1); + // just set, so should be fine lru.set('third', 3); + assert.equal(lru.get('third'), 3); + // these are too old assert.equal(lru.get('first'), undefined); assert.equal(lru.get('second'), undefined); - assert.equal(lru.get('third'), 3); }); }); });