diff --git a/src/auth/downscopedclient.ts b/src/auth/downscopedclient.ts index 0c668029..59305d27 100644 --- a/src/auth/downscopedclient.ts +++ b/src/auth/downscopedclient.ts @@ -12,7 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GaxiosOptions, GaxiosPromise, GaxiosResponse} from 'gaxios'; +import { + GaxiosError, + GaxiosOptions, + GaxiosPromise, + GaxiosResponse, +} from 'gaxios'; +import * as stream from 'stream'; import {BodyResponseCallback} from '../transporters'; import {Credentials} from './credentials'; @@ -122,11 +128,14 @@ export class DownscopedClient extends AuthClient { * @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. */ constructor( private readonly authClient: AuthClient, private readonly credentialAccessBoundary: CredentialAccessBoundary, - additionalOptions?: RefreshOptions + additionalOptions?: RefreshOptions, + quotaProjectId?: string ) { super(); // Check 1-10 Access Boundary Rules are defined within Credential Access @@ -168,6 +177,7 @@ export class DownscopedClient extends AuthClient { .eagerRefreshThresholdMillis as number; } this.forceRefreshOnFailure = !!additionalOptions?.forceRefreshOnFailure; + this.quotaProjectId = quotaProjectId; } /** @@ -214,7 +224,11 @@ export class DownscopedClient extends AuthClient { * { Authorization: 'Bearer ' } */ async getRequestHeaders(): Promise { - throw new Error('Not implemented.'); + const accessTokenResponse = await this.getAccessToken(); + const headers: Headers = { + Authorization: `Bearer ${accessTokenResponse.token}`, + }; + return this.addSharedMetadataHeaders(headers); } /** @@ -232,7 +246,65 @@ export class DownscopedClient extends AuthClient { opts: GaxiosOptions, callback?: BodyResponseCallback ): GaxiosPromise | void { - throw new Error('Not implemented.'); + 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; } /** diff --git a/src/index.ts b/src/index.ts index 35cf959c..4d6519e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,10 @@ export { BaseExternalAccountClient, BaseExternalAccountClientOptions, } from './auth/baseexternalclient'; +export { + CredentialAccessBoundary, + DownscopedClient, +} from './auth/downscopedclient'; export {DefaultTransporter} from './transporters'; const auth = new GoogleAuth(); diff --git a/test/test.downscopedclient.ts b/test/test.downscopedclient.ts index c41a0945..1b70b4e2 100644 --- a/test/test.downscopedclient.ts +++ b/test/test.downscopedclient.ts @@ -17,7 +17,7 @@ import {describe, it, beforeEach, afterEach} from 'mocha'; import * as nock from 'nock'; import * as sinon from 'sinon'; -import {GaxiosOptions, GaxiosPromise} from 'gaxios'; +import {GaxiosError, GaxiosOptions, GaxiosPromise} from 'gaxios'; import {Credentials} from '../src/auth/credentials'; import {StsSuccessfulResponse} from '../src/auth/stscredentials'; import { @@ -268,6 +268,31 @@ describe('DownscopedClient', () => { }); }); + it('should not throw with an optional quota_project_id', () => { + const quotaProjectId = 'quota_project_id'; + const cabWithOneAccessBoundaryRule = { + accessBoundary: { + accessBoundaryRules: [ + { + availableResource: testAvailableResource, + availablePermissions: [testAvailablePermission1], + availabilityCondition: { + expression: testAvailabilityConditionExpression, + }, + }, + ], + }, + }; + assert.doesNotThrow(() => { + return new DownscopedClient( + client, + cabWithOneAccessBoundaryRule, + undefined, + quotaProjectId + ); + }); + }); + it('should set custom RefreshOptions', () => { const refreshOptions = { eagerRefreshThresholdMillis: 5000, @@ -683,30 +708,562 @@ describe('DownscopedClient', () => { }); describe('getRequestHeader()', () => { - it('should return unimplemented error when calling getRequestHeader()', async () => { - const expectedError = new Error('Not implemented.'); + it('should inject the authorization headers', async () => { + const expectedHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]); + + const cabClient = new DownscopedClient(client, testClientAccessBoundary); + const actualHeaders = await cabClient.getRequestHeaders(); + + assert.deepStrictEqual(actualHeaders, expectedHeaders); + scope.done(); + }); + + it('should inject the authorization and metadata headers', async () => { + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const expectedHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + 'x-goog-user-project': quotaProjectId, + }; + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]); + + const cabClient = new DownscopedClient( + client, + testClientAccessBoundary, + undefined, + quotaProjectId + ); + const actualHeaders = await cabClient.getRequestHeaders(); + + assert.deepStrictEqual(expectedHeaders, actualHeaders); + 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 = mockStsTokenExchange([ + { + statusCode: 400, + response: errorResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]); + const cabClient = new DownscopedClient(client, testClientAccessBoundary); - await assert.rejects(cabClient.getRequestHeaders(), expectedError); + await assert.rejects( + cabClient.getRequestHeaders(), + getErrorFromOAuthErrorResponse(errorResponse) + ); + scope.done(); }); }); describe('request()', () => { - it('should return unimplemented error when request with opts', () => { + it('should process HTTP request with authorization header', async () => { + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + 'x-goog-user-project': quotaProjectId, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const cabClient = new DownscopedClient( + client, + testClientAccessBoundary, + undefined, + quotaProjectId + ); + const actualResponse = await cabClient.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 process headerless HTTP request', async () => { + const quotaProjectId = 'QUOTA_PROJECT_ID'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + 'x-goog-user-project': quotaProjectId, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const cabClient = new DownscopedClient( + client, + testClientAccessBoundary, + undefined, + quotaProjectId + ); + // Send request with no headers. + const actualResponse = await cabClient.request({ + url: 'https://example.com/api', + method: 'POST', + 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 = mockStsTokenExchange([ + { + statusCode: 400, + response: errorResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]); + const cabClient = new DownscopedClient(client, testClientAccessBoundary); + await assert.rejects( + cabClient.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 ${stsSuccessfulResponse.access_token}`, + }; const exampleRequest = { key1: 'value1', key2: 'value2', }; - const expectedError = new Error('Not implemented.'); + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; - assert.throws(() => { - return cabClient.request({ + const cabClient = new DownscopedClient(client, testClientAccessBoundary); + cabClient.request( + { url: 'https://example.com/api', method: 'POST', + headers: exampleHeaders, data: exampleRequest, responseType: 'json', - }); - }, expectedError); + }, + (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 ${stsSuccessfulResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(400, errorMessage), + ]; + + const cabClient = new DownscopedClient(client, testClientAccessBoundary); + cabClient.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 stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'DOWNSCOPED_CLIENT_ACCESS_TOKEN_1'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const authHeaders2 = { + Authorization: `Bearer ${stsSuccessfulResponse2.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleResponse: SampleResponse = { + foo: 'a', + bar: 1, + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_1', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(401) + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders2), + }) + .reply(200, Object.assign({}, exampleResponse)), + ]; + + const cabClient = new DownscopedClient(client, testClientAccessBoundary, { + forceRefreshOnFailure: true, + }); + const actualResponse = await cabClient.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 ${stsSuccessfulResponse.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(401), + ]; + + const cabClient = new DownscopedClient(client, testClientAccessBoundary, { + forceRefreshOnFailure: false, + }); + await assert.rejects( + cabClient.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 stsSuccessfulResponse2 = Object.assign({}, stsSuccessfulResponse); + stsSuccessfulResponse2.access_token = 'DOWNSCOPED_CLIENT_ACCESS_TOKEN_1'; + const authHeaders = { + Authorization: `Bearer ${stsSuccessfulResponse.access_token}`, + }; + const authHeaders2 = { + Authorization: `Bearer ${stsSuccessfulResponse2.access_token}`, + }; + const exampleRequest = { + key1: 'value1', + key2: 'value2', + }; + const exampleHeaders = { + custom: 'some-header-value', + other: 'other-header-value', + }; + const scopes = [ + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_0', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + { + statusCode: 200, + response: stsSuccessfulResponse2, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + subject_token: 'subject_token_1', + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + options: JSON.stringify(testClientAccessBoundary), + }, + }, + ]), + nock('https://example.com') + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders), + }) + .reply(403) + .post('/api', exampleRequest, { + reqheaders: Object.assign({}, exampleHeaders, authHeaders2), + }) + .reply(403), + ]; + + const cabClient = new DownscopedClient(client, testClientAccessBoundary, { + forceRefreshOnFailure: true, + }); + await assert.rejects( + cabClient.request({ + url: 'https://example.com/api', + method: 'POST', + headers: exampleHeaders, + data: exampleRequest, + responseType: 'json', + }), + { + code: '403', + } + ); + scopes.forEach(scope => scope.done()); }); }); }); diff --git a/test/test.index.ts b/test/test.index.ts index 3d9b0457..822eda1c 100644 --- a/test/test.index.ts +++ b/test/test.index.ts @@ -42,6 +42,7 @@ describe('index', () => { assert(gal.IdentityPoolClient); assert(gal.AwsClient); assert(gal.BaseExternalAccountClient); + assert(gal.DownscopedClient); assert(gal.Impersonated); }); });