From 371ce8b4df71fdbf8acae4019d0d8e2c398bbdab Mon Sep 17 00:00:00 2001 From: aeitzman Date: Mon, 13 Mar 2023 09:47:51 -0700 Subject: [PATCH 1/7] feature: adding external account authorized user client --- .../externalAccountAuthorizedUserClient.ts | 336 +++++++ src/auth/googleauth.ts | 11 + ...external-account-authorized-user-cred.json | 9 + ...est.externalaccountauthorizeduserclient.ts | 820 ++++++++++++++++++ test/test.googleauth.ts | 117 +++ 5 files changed, 1293 insertions(+) create mode 100644 src/auth/externalAccountAuthorizedUserClient.ts create mode 100644 test/fixtures/external-account-authorized-user-cred.json create mode 100644 test/test.externalaccountauthorizeduserclient.ts diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts new file mode 100644 index 00000000..634e1614 --- /dev/null +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -0,0 +1,336 @@ +// 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 {AuthClient} from './authclient'; +import {Headers, RefreshOptions} from './oauth2client'; +import { + ClientAuthentication, + getErrorFromOAuthErrorResponse, + OAuthClientAuthHandler, + OAuthErrorResponse, +} from './oauth2common'; +import {BodyResponseCallback, DefaultTransporter} from '../transporters'; +import { + GaxiosError, + GaxiosOptions, + GaxiosPromise, + GaxiosResponse, +} from 'gaxios'; +import {Credentials} from './credentials'; +import * as stream from 'stream'; +import {EXPIRATION_TIME_OFFSET} from './baseexternalclient'; + +/** + * External Account Authorized User Credentials JSON interface. + */ +export interface ExternalAccountAuthorizedUserClientOptions { + type: string; + audience: string; + client_id: string; + client_secret: string; + refresh_token: string; + token_url: string; + token_info_url: string; + revoke_url?: string; + quota_project_id?: string; +} + +/** + * The credentials JSON file type for external account authorized user clients. + */ +export const EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = + 'external_account_authorized_user'; + +/** + * Internal interface for tracking the access token expiration time. + */ +interface CredentialsWithResponse extends Credentials { + res?: GaxiosResponse | null; +} + +/** + * Interface representing the token refresh response from the sts endpoint. + */ +interface TokenRefreshResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + res?: GaxiosResponse | null; +} + +/** + * Handler for token refresh requests sent to the sts endpoint for external + * authorized user credentials. + */ +class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { + /** + * Initializes an ExternalAccountAuthorizedUserHandler instance. + * @param url The URL of the token refresh endpoint. + * @param transporter The transporter to use for the refresh request. + * @param clientAuthentication The client authentication credentials to use + * for the refresh request. + */ + constructor( + private readonly url: string, + private readonly transporter: DefaultTransporter, + clientAuthentication?: ClientAuthentication + ) { + super(clientAuthentication); + } + + /** + * Requests a new access token from the sts endpoint using the provided + * refresh token. + * @param refreshToken The refresh token to use to generate a new access token. + * @param additionalHeaders Optional additional headers to pass along the + * request. + * @return A promise that resolves with the token refresh response containing + * the requested access token and its expiration time. + */ + async refreshToken( + refreshToken: string, + additionalHeaders?: Headers + ): Promise { + const values = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + // Inject additional STS headers if available. + Object.assign(headers, additionalHeaders || {}); + + const opts: GaxiosOptions = { + url: this.url, + method: 'POST', + headers, + data: values.toString(), + responseType: 'json', + }; + // Apply OAuth client authentication. + this.applyClientAuthenticationOptions(opts); + + try { + const response = await this.transporter.request( + opts + ); + // Successful response. + const tokenRefreshResponse = response.data; + tokenRefreshResponse.res = response; + return tokenRefreshResponse; + } catch (error) { + // Translate error to OAuthError. + if (error instanceof GaxiosError && error.response) { + throw getErrorFromOAuthErrorResponse( + error.response.data as OAuthErrorResponse, + // Preserve other fields from the original error. + error + ); + } + // Request could fail before the server responds. + throw error; + } + } +} + +/** + * External Account Authorized User Client. This is used for OAuth2 credentials + * sourced using external identities through Workforce Identity Federation. + * Obtaining the initial access and refresh token can be done through the + * Google Cloud CLI. + */ +export class ExternalAccountAuthorizedUserClient extends AuthClient { + private cachedAccessToken: CredentialsWithResponse | null; + private readonly externalAccountAuthorizedUserHandler: ExternalAccountAuthorizedUserHandler; + private refreshToken: string; + + /** + * Instantiates an ExternalAccountAuthorizedUserClient instances using the + * provided JSON object loaded from a credentials files. + * 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. + */ + constructor( + options: ExternalAccountAuthorizedUserClientOptions, + additionalOptions?: RefreshOptions + ) { + super(); + if (options.type !== EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE) { + throw new Error( + `Expected "${EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE}" type but ` + + `received "${options.type}"` + ); + } + this.refreshToken = options.refresh_token; + const clientAuth = { + confidentialClientType: 'basic', + clientId: options.client_id, + clientSecret: options.client_secret, + } as ClientAuthentication; + this.externalAccountAuthorizedUserHandler = + new ExternalAccountAuthorizedUserHandler( + options.token_url, + this.transporter, + clientAuth + ); + + this.cachedAccessToken = null; + this.quotaProjectId = options.quota_project_id; + + // As threshold could be zero, + // eagerRefreshThresholdMillis || EXPIRATION_TIME_OFFSET will override the + // zero value. + if (typeof additionalOptions?.eagerRefreshThresholdMillis !== 'number') { + this.eagerRefreshThresholdMillis = EXPIRATION_TIME_OFFSET; + } else { + this.eagerRefreshThresholdMillis = additionalOptions! + .eagerRefreshThresholdMillis as number; + } + this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; + } + + async getAccessToken(): Promise<{ + token?: string | null; + res?: GaxiosResponse | null; + }> { + // If cached access token is unavailable or expired, force refresh. + if (!this.cachedAccessToken || this.isExpired(this.cachedAccessToken)) { + await this.refreshAccessTokenAsync(); + } + // Return GCP access token in GetAccessTokenResponse format. + return { + token: this.cachedAccessToken!.access_token, + res: this.cachedAccessToken!.res, + }; + } + + async getRequestHeaders(url?: string): Promise { + const accessTokenResponse = await this.getAccessToken(); + const headers: Headers = { + Authorization: `Bearer ${accessTokenResponse.token}`, + }; + return this.addSharedMetadataHeaders(headers); + } + + request(opts: GaxiosOptions): GaxiosPromise; + request(opts: GaxiosOptions, callback: BodyResponseCallback): void; + request( + opts: GaxiosOptions, + callback?: BodyResponseCallback + ): GaxiosPromise | void { + if (callback) { + this.requestAsync(opts).then( + r => callback(null, r), + e => { + return callback(e, e.response); + } + ); + } else { + return this.requestAsync(opts); + } + } + + /** + * Authenticates the provided HTTP request, processes it and resolves with the + * returned response. + * @param opts The HTTP request options. + * @param retry Whether the current attempt is a retry after a failed attempt. + * @return A promise that resolves with the successful response. + */ + protected async requestAsync( + opts: GaxiosOptions, + retry = false + ): Promise> { + let response: GaxiosResponse; + try { + const requestHeaders = await this.getRequestHeaders(); + opts.headers = opts.headers || {}; + if (requestHeaders && requestHeaders['x-goog-user-project']) { + opts.headers['x-goog-user-project'] = + requestHeaders['x-goog-user-project']; + } + if (requestHeaders && requestHeaders.Authorization) { + opts.headers.Authorization = requestHeaders.Authorization; + } + response = await this.transporter.request(opts); + } catch (e) { + const res = (e as GaxiosError).response; + if (res) { + const statusCode = res.status; + // Retry the request for metadata if the following criteria are true: + // - We haven't already retried. It only makes sense to retry once. + // - The response was a 401 or a 403 + // - The request didn't send a readableStream + // - forceRefreshOnFailure is true + const isReadableStream = res.config.data instanceof stream.Readable; + const isAuthErr = statusCode === 401 || statusCode === 403; + if ( + !retry && + isAuthErr && + !isReadableStream && + this.forceRefreshOnFailure + ) { + await this.refreshAccessTokenAsync(); + return await this.requestAsync(opts, true); + } + } + throw e; + } + return response; + } + + /** + * Forces token refresh, even if unexpired tokens are currently cached. + * @return A promise that resolves with the refreshed credential. + */ + protected async refreshAccessTokenAsync(): Promise { + // Exchange the source AuthClient access token for a Downscoped access + // token. + const refreshResponse = + await this.externalAccountAuthorizedUserHandler.refreshToken( + this.refreshToken + ); + + this.cachedAccessToken = { + access_token: refreshResponse.access_token, + expiry_date: new Date().getTime() + refreshResponse.expires_in * 1000, + res: refreshResponse.res, + }; + + if (refreshResponse.refresh_token !== undefined) { + this.refreshToken = refreshResponse.refresh_token; + } + + return this.cachedAccessToken; + } + + /** + * Returns whether the provided credentials are expired or not. + * If there is no expiry time, assumes the token is not expired or expiring. + * @param credentials The credentials to check for expiration. + * @return Whether the credentials are expired or not. + */ + private isExpired(credentials: Credentials): boolean { + const now = new Date().getTime(); + return credentials.expiry_date + ? now >= credentials.expiry_date - this.eagerRefreshThresholdMillis + : false; + } +} diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 41f2a48e..77566cfb 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -48,6 +48,11 @@ import { BaseExternalAccountClient, } from './baseexternalclient'; import {AuthClient} from './authclient'; +import { + EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, + ExternalAccountAuthorizedUserClient, + ExternalAccountAuthorizedUserClientOptions, +} from './externalAccountAuthorizedUserClient'; /** * Defines all types of explicit clients that are determined via ADC JSON @@ -57,6 +62,7 @@ export type JSONClient = | JWT | UserRefreshClient | BaseExternalAccountClient + | ExternalAccountAuthorizedUserClient | Impersonated; export interface ProjectIdCallback { @@ -589,6 +595,11 @@ export class GoogleAuth { options )!; client.scopes = this.getAnyScopes(); + } else if (json.type === EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE) { + client = new ExternalAccountAuthorizedUserClient( + json as ExternalAccountAuthorizedUserClientOptions, + options + ); } else { (options as JWTOptions).scopes = this.scopes; client = new JWT(options); diff --git a/test/fixtures/external-account-authorized-user-cred.json b/test/fixtures/external-account-authorized-user-cred.json new file mode 100644 index 00000000..38273470 --- /dev/null +++ b/test/fixtures/external-account-authorized-user-cred.json @@ -0,0 +1,9 @@ +{ + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID", + "client_id": "clientId", + "client_secret": "clientSecret", + "refresh_token": "refreshToken", + "token_url": "https://sts.googleapis.com/v1/oauthtoken", + "token_info_url": "https://sts.googleapis.com/v1/introspect" +} diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts new file mode 100644 index 00000000..1db58bea --- /dev/null +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -0,0 +1,820 @@ +// 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 * as assert from 'assert'; +import {describe, it, afterEach, beforeEach} from 'mocha'; +import * as nock from 'nock'; +import * as sinon from 'sinon'; +import * as qs from 'querystring'; +import { + assertGaxiosResponsePresent, + getAudience, + mockStsTokenExchange, +} from './externalclienthelper'; +import { + EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, + ExternalAccountAuthorizedUserClient, +} from '../src/auth/externalAccountAuthorizedUserClient'; +import {EXPIRATION_TIME_OFFSET} from '../src/auth/baseexternalclient'; +import {GaxiosError, GaxiosResponse} from 'gaxios'; +import { + getErrorFromOAuthErrorResponse, + OAuthErrorResponse, +} from '../src/auth/oauth2common'; + +nock.disableNetConnect(); + +describe('ExternalAccountAuthorizedUserClient', () => { + const BASE_URL = 'https://sts.googleapis.com'; + const REFRESH_PATH = '/v1/oauthtoken'; + const TOKEN_REFRESH_URL = `${BASE_URL}${REFRESH_PATH}`; + const TOKEN_INFO_URL = 'https://sts.googleapis.com/v1/introspect'; + + interface TokenRefreshResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + res?: GaxiosResponse | null; + } + + interface NockMockRefreshResponse { + statusCode: number; + response: TokenRefreshResponse | OAuthErrorResponse; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request: {[key: string]: any}; + times?: number; + additionalHeaders?: {[key: string]: string}; + } + + function mockStsTokenRefresh( + url: string, + path: string, + nockParams: NockMockRefreshResponse[] + ): nock.Scope { + const scope = nock(url); + nockParams.forEach(nockMockStsToken => { + const times = + nockMockStsToken.times !== undefined ? nockMockStsToken.times : 1; + const headers = Object.assign( + { + 'content-type': 'application/x-www-form-urlencoded', + }, + nockMockStsToken.additionalHeaders || {} + ); + scope + .post(path, qs.stringify(nockMockStsToken.request), { + reqheaders: headers, + }) + .times(times) + .reply(nockMockStsToken.statusCode, nockMockStsToken.response); + }); + return scope; + } + + let clock: sinon.SinonFakeTimers; + const referenceDate = new Date('2020-08-11T06:55:22.345Z'); + const audience = getAudience(); + const externalAccountAuthorizedUserCredentialOptions = { + type: EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, + audience: audience, + client_id: 'clientId', + client_secret: 'clientSecret', + refresh_token: 'refreshToken', + token_url: TOKEN_REFRESH_URL, + token_info_url: TOKEN_INFO_URL, + }; + const successfulRefreshResponse = { + access_token: 'newAccessToken', + refresh_token: 'newRefreshToken', + expires_in: 3600, + }; + const successfulRefreshResponseNoRefreshToken = { + access_token: 'newAccessToken', + expires_in: 3600, + }; + beforeEach(() => { + clock = sinon.useFakeTimers(referenceDate); + }); + + afterEach(() => { + if (clock) { + clock.restore(); + } + }); + + describe('Constructor', () => { + it('should throw on invalid type', () => { + const expectedError = new Error( + 'Expected "external_account_authorized_user" type but received "invalid"' + ); + const invalidOptions = Object.assign( + {}, + externalAccountAuthorizedUserCredentialOptions + ); + invalidOptions.type = 'invalid'; + + assert.throws(() => { + return new ExternalAccountAuthorizedUserClient(invalidOptions); + }, expectedError); + }); + + it('should not throw when valid options are provided', () => { + assert.doesNotThrow(() => { + return new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + }); + }); + + it('should set default RefreshOptions', () => { + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + + assert(!client.forceRefreshOnFailure); + assert(client.eagerRefreshThresholdMillis === EXPIRATION_TIME_OFFSET); + }); + + it('should set custom RefreshOptions', () => { + const refreshOptions = { + eagerRefreshThresholdMillis: 5000, + forceRefreshOnFailure: true, + }; + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions, + refreshOptions + ); + + assert.strictEqual( + client.forceRefreshOnFailure, + refreshOptions.forceRefreshOnFailure + ); + assert.strictEqual( + client.eagerRefreshThresholdMillis, + refreshOptions.eagerRefreshThresholdMillis + ); + }); + }); + + describe('getAccessToken()', () => { + it('should resolve with the expected response', async () => { + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + const actualResponse = await client.getAccessToken(); + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: successfulRefreshResponse.access_token, + }); + scope.done(); + }); + + it('should handle refresh errors', async () => { + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid refresh token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 400, + response: errorResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + await assert.rejects( + client.getAccessToken(), + getErrorFromOAuthErrorResponse(errorResponse) + ); + scope.done(); + }); + + it('should handle refresh timeout', async () => { + const expectedRequest = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }); + + const scope = nock(BASE_URL) + .post(REFRESH_PATH, expectedRequest.toString(), { + reqheaders: { + 'content-type': 'application/x-www-form-urlencoded', + }, + }) + .replyWithError({code: 'ETIMEDOUT'}); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + await assert.rejects(client.getAccessToken(), { + code: 'ETIMEDOUT', + }); + scope.done(); + }); + + it('should use the new refresh token', async () => { + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: + externalAccountAuthorizedUserCredentialOptions.refresh_token, + }, + }, + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: successfulRefreshResponse.refresh_token, + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + // Get initial access token and new refresh token. + await client.getAccessToken(); + // Advance clock to force new refresh. + clock.tick((successfulRefreshResponse.expires_in + 1) * 1000); + // Refresh access token with new access token. + const actualResponse = await client.getAccessToken(); + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: successfulRefreshResponse.access_token, + }); + + scope.done(); + }); + + it('should not call refresh when token is cached', async () => { + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + // Get initial access token and new refresh token. + await client.getAccessToken(); + // Advance clock to force new refresh. + clock.tick( + successfulRefreshResponseNoRefreshToken.expires_in * 1000 - + client.eagerRefreshThresholdMillis - + 1 + ); + // Refresh access token with new access token. + const actualResponse = await client.getAccessToken(); + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: successfulRefreshResponse.access_token, + }); + + scope.done(); + }); + + it('should refresh when cached token is expired', async () => { + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + times: 2, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + // Get initial access token. + await client.getAccessToken(); + // Advance clock to force new refresh. + clock.tick( + successfulRefreshResponseNoRefreshToken.expires_in * 1000 - + client.eagerRefreshThresholdMillis + + 1 + ); + // Refresh access token with new access token. + const actualResponse = await client.getAccessToken(); + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: successfulRefreshResponse.access_token, + }); + + scope.done(); + }); + }); + + describe('getRequestHeaders()', () => { + it('should inject the authorization headers', async () => { + const expectedHeaders = { + Authorization: `Bearer ${successfulRefreshResponseNoRefreshToken.access_token}`, + 'x-goog-user-project': 'quotaProjectId', + }; + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const optionsWithQuotaProjectId = Object.assign( + {quota_project_id: 'quotaProjectId'}, + externalAccountAuthorizedUserCredentialOptions + ); + const client = new ExternalAccountAuthorizedUserClient( + optionsWithQuotaProjectId + ); + const actualHeaders = await client.getRequestHeaders(); + + assert.deepStrictEqual(actualHeaders, expectedHeaders); + scope.done(); + }); + + it('should reject when error occurs during token retrieval', async () => { + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid subject token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 400, + response: errorResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + await assert.rejects( + client.getRequestHeaders(), + getErrorFromOAuthErrorResponse(errorResponse) + ); + scope.done(); + }); + }); + + describe('request()', () => { + it('should process HTTP request with authorization header', async () => { + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const authHeaders = { + Authorization: `Bearer ${successfulRefreshResponse.access_token}`, + 'x-goog-user-project': quotaProjectId, + }; + const optionsWithQuotaProjectId = Object.assign( + {quota_project_id: quotaProjectId}, + externalAccountAuthorizedUserCredentialOptions + ); + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new ExternalAccountAuthorizedUserClient( + optionsWithQuotaProjectId + ); + const actualResponse = await client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }); + + assert.deepStrictEqual(actualResponse.data, exampleResponse); + scopes.forEach(scope => scope.done()); + }); + + it('should reject when error occurs during token retrieval', async () => { + const errorResponse: OAuthErrorResponse = { + error: 'invalid_request', + error_description: 'Invalid subject token', + error_uri: 'https://tools.ietf.org/html/rfc6749#section-5.2', + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const scope = mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 400, + response: errorResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]); + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + await assert.rejects( + client.request({ + url: 'https://example.com/api', + method: 'POST', + data: exampleRequest, + responseType: 'json', + }), + getErrorFromOAuthErrorResponse(errorResponse) + ); + scope.done(); + }); + + it('should trigger callback on success when provided', done => { + const authHeaders = { + Authorization: `Bearer ${successfulRefreshResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + client.request( + { + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }, + (err, result) => { + assert.strictEqual(err, null); + assert.deepStrictEqual(result?.data, exampleResponse); + scopes.forEach(scope => scope.done()); + done(); + } + ); + }); + + it('should trigger callback on error when provided', done => { + const errorMessage = 'Bad Request'; + const authHeaders = { + Authorization: `Bearer ${successfulRefreshResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(400, errorMessage), + ]; + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + client.request( + { + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }, + (err, result) => { + assert.strictEqual(err!.message, errorMessage); + assert.deepStrictEqual(result, (err as GaxiosError)!.response); + scopes.forEach(scope => scope.done()); + done(); + } + ); + }); + + it('should retry on 401 on forceRefreshOnFailure=true', async () => { + const authHeaders = { + Authorization: `Bearer ${successfulRefreshResponseNoRefreshToken.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + times: 2, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(401) + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions, + { + forceRefreshOnFailure: true, + } + ); + const actualResponse = await client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }); + + assert.deepStrictEqual(actualResponse.data, exampleResponse); + scopes.forEach(scope => scope.done()); + }); + + it('should not retry on 401 on forceRefreshOnFailure=false', async () => { + const authHeaders = { + Authorization: `Bearer ${successfulRefreshResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponse, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(401), + ]; + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions, + { + forceRefreshOnFailure: false, + } + ); + await assert.rejects( + client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }), + { + code: '401', + } + ); + + scopes.forEach(scope => scope.done()); + }); + + it('should not retry more than once', async () => { + const authHeaders = { + Authorization: `Bearer ${successfulRefreshResponseNoRefreshToken.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + times: 2, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(403) + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(403), + ]; + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions, + { + forceRefreshOnFailure: true, + } + ); + await assert.rejects( + client.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }), + { + code: '403', + } + ); + scopes.forEach(scope => scope.done()); + }); + + it('should process headerless HTTP request', async () => { + const authHeaders = { + Authorization: `Bearer ${successfulRefreshResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse = { + foo: 'a', + bar: 1, + }; + const scopes = [ + mockStsTokenRefresh(BASE_URL, REFRESH_PATH, [ + { + statusCode: 200, + response: successfulRefreshResponseNoRefreshToken, + request: { + grant_type: 'refresh_token', + refresh_token: 'refreshToken', + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); + // Send request with no headers. + const actualResponse = await client.request({ + url: 'https://example.com/api', + method: 'POST', + data: exampleRequest, + responseType: 'json', + }); + + assert.deepStrictEqual(actualResponse.data, exampleResponse); + scopes.forEach(scope => scope.done()); + }); + }); +}); diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index e5c99424..1936fe30 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -53,6 +53,10 @@ import { } from './externalclienthelper'; import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; import {AuthClient} from '../src/auth/authclient'; +import { + ExternalAccountAuthorizedUserClient, + ExternalAccountAuthorizedUserClientOptions, +} from '../src/auth/externalAccountAuthorizedUserClient'; nock.disableNetConnect(); @@ -82,6 +86,8 @@ describe('googleauth', () => { const refreshJSON = require('../../test/fixtures/refresh.json'); // eslint-disable-next-line @typescript-eslint/no-var-requires const externalAccountJSON = require('../../test/fixtures/external-account-cred.json'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const externalAccountAuthorizedUserJSON = require('../../test/fixtures/external-account-authorized-user-cred.json'); const privateKey = fs.readFileSync('./test/fixtures/private.pem', 'utf-8'); const wellKnownPathWindows = path.join( 'C:', @@ -2457,6 +2463,117 @@ describe('googleauth', () => { }); }); }); + + describe('for external_account_authorized_user types', () => { + /** + * @return A copy of the external account authorized user JSON auth object + * for testing. + */ + function createExternalAccountAuthorizedUserJson() { + const jsonCopy = Object.assign({}, externalAccountAuthorizedUserJSON); + return jsonCopy; + } + + describe('fromJSON()', () => { + it('should create the expected BaseExternalAccountClient', () => { + const json = createExternalAccountAuthorizedUserJson(); + const result = auth.fromJSON(json); + assert(result instanceof ExternalAccountAuthorizedUserClient); + }); + }); + + describe('fromStream()', () => { + it('should read the stream and create a client', async () => { + const stream = fs.createReadStream( + './test/fixtures/external-account-authorized-user-cred.json' + ); + const actualClient = await auth.fromStream(stream); + + assert(actualClient instanceof ExternalAccountAuthorizedUserClient); + }); + }); + + describe('getApplicationDefault()', () => { + it('should use environment variable when it is set', async () => { + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-authorized-user-cred.json' + ); + + const res = await auth.getApplicationDefault(); + const actualClient = res.credential; + + assert(actualClient instanceof ExternalAccountAuthorizedUserClient); + }); + + it('should use well-known file when it is available and env const is not set', async () => { + mockLinuxWellKnownFile( + './test/fixtures/external-account-authorized-user-cred.json' + ); + + const res = await auth.getApplicationDefault(); + const actualClient = res.credential; + + assert(actualClient instanceof ExternalAccountAuthorizedUserClient); + }); + }); + + describe('getApplicationCredentialsFromFilePath()', () => { + it('should correctly read the file and create a valid client', async () => { + const actualClient = + await auth._getApplicationCredentialsFromFilePath( + './test/fixtures/external-account-authorized-user-cred.json' + ); + + assert(actualClient instanceof ExternalAccountAuthorizedUserClient); + }); + }); + + describe('getClient()', () => { + it('should initialize from credentials', async () => { + const auth = new GoogleAuth({ + credentials: createExternalAccountAuthorizedUserJson(), + }); + const actualClient = await auth.getClient(); + + assert(actualClient instanceof ExternalAccountAuthorizedUserClient); + }); + + it('should initialize from keyFileName', async () => { + const keyFilename = + './test/fixtures/external-account-authorized-user-cred.json'; + const auth = new GoogleAuth({keyFilename}); + const actualClient = await auth.getClient(); + + assert(actualClient instanceof ExternalAccountAuthorizedUserClient); + }); + + it('should initialize from ADC', async () => { + // Set up a mock to return path to a valid credentials file. + mockEnvVar( + 'GOOGLE_APPLICATION_CREDENTIALS', + './test/fixtures/external-account-authorized-user-cred.json' + ); + const auth = new GoogleAuth(); + const actualClient = await auth.getClient(); + + assert(actualClient instanceof ExternalAccountAuthorizedUserClient); + }); + }); + + describe('sign()', () => { + it('should reject', async () => { + const auth = new GoogleAuth({ + credentials: createExternalAccountAuthorizedUserJson(), + }); + + await assert.rejects( + auth.sign('abc123'), + /Cannot sign data without `client_email`/ + ); + }); + }); + }); }); // Allows a client to be instantiated from a certificate, From 1f8f1d40c8db45bae99d9c2688cb8e8f311e28e7 Mon Sep 17 00:00:00 2001 From: aeitzman Date: Mon, 13 Mar 2023 09:53:00 -0700 Subject: [PATCH 2/7] fix: fix comment --- src/auth/externalAccountAuthorizedUserClient.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index 634e1614..4997e1c7 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -301,8 +301,7 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { * @return A promise that resolves with the refreshed credential. */ protected async refreshAccessTokenAsync(): Promise { - // Exchange the source AuthClient access token for a Downscoped access - // token. + // Refresh the access token using the refresh token. const refreshResponse = await this.externalAccountAuthorizedUserHandler.refreshToken( this.refreshToken From fc345b570b9e2013dcf0c306ed40722f06ab33ea Mon Sep 17 00:00:00 2001 From: aeitzman Date: Tue, 11 Apr 2023 09:38:48 -0700 Subject: [PATCH 3/7] addressing code review --- src/auth/externalAccountAuthorizedUserClient.ts | 10 +++++----- test/test.externalaccountauthorizeduserclient.ts | 8 ++------ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index 4997e1c7..eeb90e29 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -60,7 +60,7 @@ interface CredentialsWithResponse extends Credentials { } /** - * Interface representing the token refresh response from the sts endpoint. + * Internal interface representing the token refresh response from the token_url endpoint. */ interface TokenRefreshResponse { access_token: string; @@ -70,7 +70,7 @@ interface TokenRefreshResponse { } /** - * Handler for token refresh requests sent to the sts endpoint for external + * Handler for token refresh requests sent to the token_url endpoint for external * authorized user credentials. */ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { @@ -90,7 +90,7 @@ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { } /** - * Requests a new access token from the sts endpoint using the provided + * Requests a new access token from the token_url endpoint using the provided * refresh token. * @param refreshToken The refresh token to use to generate a new access token. * @param additionalHeaders Optional additional headers to pass along the @@ -110,7 +110,7 @@ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { const headers = { 'Content-Type': 'application/x-www-form-urlencoded', }; - // Inject additional STS headers if available. + // Inject additional headers if available. Object.assign(headers, additionalHeaders || {}); const opts: GaxiosOptions = { @@ -221,7 +221,7 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { }; } - async getRequestHeaders(url?: string): Promise { + async getRequestHeaders(): Promise { const accessTokenResponse = await this.getAccessToken(); const headers: Headers = { Authorization: `Bearer ${accessTokenResponse.token}`, diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts index 1db58bea..b27d195f 100644 --- a/test/test.externalaccountauthorizeduserclient.ts +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -17,11 +17,7 @@ import {describe, it, afterEach, beforeEach} from 'mocha'; import * as nock from 'nock'; import * as sinon from 'sinon'; import * as qs from 'querystring'; -import { - assertGaxiosResponsePresent, - getAudience, - mockStsTokenExchange, -} from './externalclienthelper'; +import {assertGaxiosResponsePresent, getAudience} from './externalclienthelper'; import { EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, ExternalAccountAuthorizedUserClient, @@ -39,7 +35,7 @@ describe('ExternalAccountAuthorizedUserClient', () => { const BASE_URL = 'https://sts.googleapis.com'; const REFRESH_PATH = '/v1/oauthtoken'; const TOKEN_REFRESH_URL = `${BASE_URL}${REFRESH_PATH}`; - const TOKEN_INFO_URL = 'https://sts.googleapis.com/v1/introspect'; + const TOKEN_INFO_URL = `${BASE_URL}/v1/introspect`; interface TokenRefreshResponse { access_token: string; From 147bb3b61caf045f504aff262eb2f7a8fc07bdb1 Mon Sep 17 00:00:00 2001 From: aeitzman Date: Tue, 11 Apr 2023 10:11:59 -0700 Subject: [PATCH 4/7] cleaning up --- test/test.googleauth.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index 1936fe30..04be4314 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -2470,8 +2470,7 @@ describe('googleauth', () => { * for testing. */ function createExternalAccountAuthorizedUserJson() { - const jsonCopy = Object.assign({}, externalAccountAuthorizedUserJSON); - return jsonCopy; + return Object.assign({}, externalAccountAuthorizedUserJSON); } describe('fromJSON()', () => { From 1a063ef15d875c251cbeaaff965bb7e777ba0b90 Mon Sep 17 00:00:00 2001 From: aeitzman <12433791+aeitzman@users.noreply.github.com> Date: Wed, 12 Apr 2023 12:56:12 -0700 Subject: [PATCH 5/7] Update src/auth/externalAccountAuthorizedUserClient.ts Co-authored-by: Daniel Bankhead --- src/auth/externalAccountAuthorizedUserClient.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index eeb90e29..0415cb60 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -109,9 +109,8 @@ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { const headers = { 'Content-Type': 'application/x-www-form-urlencoded', + ...additionalHeaders }; - // Inject additional headers if available. - Object.assign(headers, additionalHeaders || {}); const opts: GaxiosOptions = { url: this.url, From 3dc0c280733e327d9234d440a7df85191446cdd0 Mon Sep 17 00:00:00 2001 From: aeitzman Date: Wed, 12 Apr 2023 15:37:29 -0700 Subject: [PATCH 6/7] addressing review comments --- .../externalAccountAuthorizedUserClient.ts | 10 ++------- ...est.externalaccountauthorizeduserclient.ts | 22 +++---------------- 2 files changed, 5 insertions(+), 27 deletions(-) diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts index 0415cb60..c0c081cd 100644 --- a/src/auth/externalAccountAuthorizedUserClient.ts +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -35,7 +35,7 @@ import {EXPIRATION_TIME_OFFSET} from './baseexternalclient'; * External Account Authorized User Credentials JSON interface. */ export interface ExternalAccountAuthorizedUserClientOptions { - type: string; + type: typeof EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE; audience: string; client_id: string; client_secret: string; @@ -109,7 +109,7 @@ class ExternalAccountAuthorizedUserHandler extends OAuthClientAuthHandler { const headers = { 'Content-Type': 'application/x-www-form-urlencoded', - ...additionalHeaders + ...additionalHeaders, }; const opts: GaxiosOptions = { @@ -171,12 +171,6 @@ export class ExternalAccountAuthorizedUserClient extends AuthClient { additionalOptions?: RefreshOptions ) { super(); - if (options.type !== EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE) { - throw new Error( - `Expected "${EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE}" type but ` + - `received "${options.type}"` - ); - } this.refreshToken = options.refresh_token; const clientAuth = { confidentialClientType: 'basic', diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts index b27d195f..517e63f9 100644 --- a/test/test.externalaccountauthorizeduserclient.ts +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -21,6 +21,7 @@ import {assertGaxiosResponsePresent, getAudience} from './externalclienthelper'; import { EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE, ExternalAccountAuthorizedUserClient, + ExternalAccountAuthorizedUserClientOptions, } from '../src/auth/externalAccountAuthorizedUserClient'; import {EXPIRATION_TIME_OFFSET} from '../src/auth/baseexternalclient'; import {GaxiosError, GaxiosResponse} from 'gaxios'; @@ -89,7 +90,7 @@ describe('ExternalAccountAuthorizedUserClient', () => { refresh_token: 'refreshToken', token_url: TOKEN_REFRESH_URL, token_info_url: TOKEN_INFO_URL, - }; + } as ExternalAccountAuthorizedUserClientOptions; const successfulRefreshResponse = { access_token: 'newAccessToken', refresh_token: 'newRefreshToken', @@ -110,21 +111,6 @@ describe('ExternalAccountAuthorizedUserClient', () => { }); describe('Constructor', () => { - it('should throw on invalid type', () => { - const expectedError = new Error( - 'Expected "external_account_authorized_user" type but received "invalid"' - ); - const invalidOptions = Object.assign( - {}, - externalAccountAuthorizedUserCredentialOptions - ); - invalidOptions.type = 'invalid'; - - assert.throws(() => { - return new ExternalAccountAuthorizedUserClient(invalidOptions); - }, expectedError); - }); - it('should not throw when valid options are provided', () => { assert.doesNotThrow(() => { return new ExternalAccountAuthorizedUserClient( @@ -134,9 +120,7 @@ describe('ExternalAccountAuthorizedUserClient', () => { }); it('should set default RefreshOptions', () => { - const client = new ExternalAccountAuthorizedUserClient( - externalAccountAuthorizedUserCredentialOptions - ); + const client = new ExternalAccountAuthorizedUserClient(externalAccountAuthorizedUserCredentialOptions); assert(!client.forceRefreshOnFailure); assert(client.eagerRefreshThresholdMillis === EXPIRATION_TIME_OFFSET); From 853283e8a4d614a9b6a6393ba5ab12018967a648 Mon Sep 17 00:00:00 2001 From: aeitzman Date: Wed, 12 Apr 2023 15:43:40 -0700 Subject: [PATCH 7/7] lint --- test/test.externalaccountauthorizeduserclient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test.externalaccountauthorizeduserclient.ts b/test/test.externalaccountauthorizeduserclient.ts index 517e63f9..879f48e9 100644 --- a/test/test.externalaccountauthorizeduserclient.ts +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -120,7 +120,9 @@ describe('ExternalAccountAuthorizedUserClient', () => { }); it('should set default RefreshOptions', () => { - const client = new ExternalAccountAuthorizedUserClient(externalAccountAuthorizedUserCredentialOptions); + const client = new ExternalAccountAuthorizedUserClient( + externalAccountAuthorizedUserCredentialOptions + ); assert(!client.forceRefreshOnFailure); assert(client.eagerRefreshThresholdMillis === EXPIRATION_TIME_OFFSET);