diff --git a/src/auth/externalAccountAuthorizedUserClient.ts b/src/auth/externalAccountAuthorizedUserClient.ts new file mode 100644 index 00000000..c0c081cd --- /dev/null +++ b/src/auth/externalAccountAuthorizedUserClient.ts @@ -0,0 +1,328 @@ +// 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: typeof EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE; + 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; +} + +/** + * Internal interface representing the token refresh response from the token_url endpoint. + */ +interface TokenRefreshResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + res?: GaxiosResponse | null; +} + +/** + * Handler for token refresh requests sent to the token_url 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 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 + * 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', + ...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(); + 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(): 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 { + // Refresh the access token using the refresh 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..879f48e9 --- /dev/null +++ b/test/test.externalaccountauthorizeduserclient.ts @@ -0,0 +1,802 @@ +// 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} 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'; +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 = `${BASE_URL}/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, + } as ExternalAccountAuthorizedUserClientOptions; + 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 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..04be4314 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,116 @@ 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() { + return Object.assign({}, externalAccountAuthorizedUserJSON); + } + + 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,