diff --git a/src/auth/identitypoolclient.ts b/src/auth/identitypoolclient.ts index 28ae3abf..eef78622 100644 --- a/src/auth/identitypoolclient.ts +++ b/src/auth/identitypoolclient.ts @@ -24,6 +24,12 @@ import {RefreshOptions} from './oauth2client'; const readFile = promisify(fs.readFile); +type SubjectTokenFormatType = 'json' | 'text'; + +interface SubjectTokenJsonResponse { + [key: string]: string; +} + /** * Url-sourced/file-sourced credentials json interface. * This is used for K8s and Azure workloads. @@ -36,6 +42,10 @@ export interface IdentityPoolClientOptions headers?: { [key: string]: string; }; + format?: { + type: SubjectTokenFormatType; + subject_token_field_name?: string; + }; }; } @@ -47,6 +57,8 @@ export class IdentityPoolClient extends BaseExternalAccountClient { private readonly file?: string; private readonly url?: string; private readonly headers?: {[key: string]: string}; + private readonly formatType: SubjectTokenFormatType; + private readonly formatSubjectTokenFieldName?: string; /** * Instantiate an IdentityPoolClient instance using the provided JSON @@ -70,6 +82,18 @@ export class IdentityPoolClient extends BaseExternalAccountClient { if (!this.file && !this.url) { throw new Error('No valid Identity Pool "credential_source" provided'); } + // Text is the default format type. + this.formatType = options.credential_source.format?.type || 'text'; + this.formatSubjectTokenFieldName = + options.credential_source.format?.subject_token_field_name; + if (this.formatType !== 'json' && this.formatType !== 'text') { + throw new Error(`Invalid credential_source format "${this.formatType}"`); + } + if (this.formatType === 'json' && !this.formatSubjectTokenFieldName) { + throw new Error( + 'Missing subject_token_field_name for JSON credential_source format' + ); + } } /** @@ -84,9 +108,18 @@ export class IdentityPoolClient extends BaseExternalAccountClient { */ async retrieveSubjectToken(): Promise { if (this.file) { - return await this.getTokenFromFile(this.file!); + return await this.getTokenFromFile( + this.file!, + this.formatType, + this.formatSubjectTokenFieldName + ); } else { - return await this.getTokenFromUrl(this.url!, this.headers); + return await this.getTokenFromUrl( + this.url!, + this.formatType, + this.formatSubjectTokenFieldName, + this.headers + ); } } @@ -94,9 +127,17 @@ export class IdentityPoolClient extends BaseExternalAccountClient { * Looks up the external subject token in the file path provided and * resolves with that token. * @param file The file path where the external credential is located. + * @param formatType The token file or URL response type (JSON or text). + * @param formatSubjectTokenFieldName For JSON response types, this is the + * subject_token field name. For Azure, this is access_token. For text + * response types, this is ignored. * @return A promise that resolves with the external subject token. */ - private getTokenFromFile(filePath: string): Promise { + private async getTokenFromFile( + filePath: string, + formatType: SubjectTokenFormatType, + formatSubjectTokenFieldName?: string + ): Promise { // Make sure there is a file at the path. lstatSync will throw if there is // nothing there. try { @@ -112,7 +153,20 @@ export class IdentityPoolClient extends BaseExternalAccountClient { throw err; } - return readFile(filePath, {encoding: 'utf8'}); + let subjectToken: string | undefined; + const rawText = await readFile(filePath, {encoding: 'utf8'}); + if (formatType === 'text') { + subjectToken = rawText; + } else if (formatType === 'json' && formatSubjectTokenFieldName) { + const json = JSON.parse(rawText) as SubjectTokenJsonResponse; + subjectToken = json[formatSubjectTokenFieldName]; + } + if (!subjectToken) { + throw new Error( + 'Unable to parse the subject_token from the credential_source file' + ); + } + return subjectToken; } /** @@ -120,21 +174,41 @@ export class IdentityPoolClient extends BaseExternalAccountClient { * external subject token. * @param url The URL to call to retrieve the subject token. This is typically * a local metadata server. + * @param formatType The token file or URL response type (JSON or text). + * @param formatSubjectTokenFieldName For JSON response types, this is the + * subject_token field name. For Azure, this is access_token. For text + * response types, this is ignored. * @param headers The optional additional headers to send with the request to * the metadata server url. * @return A promise that resolves with the external subject token. */ private async getTokenFromUrl( url: string, + formatType: SubjectTokenFormatType, + formatSubjectTokenFieldName?: string, headers?: {[key: string]: string} ): Promise { const opts: GaxiosOptions = { url, method: 'GET', headers, - responseType: 'text', + responseType: formatType, }; - const response = await this.transporter.request(opts); - return response.data; + let subjectToken: string | undefined; + if (formatType === 'text') { + const response = await this.transporter.request(opts); + subjectToken = response.data; + } else if (formatType === 'json' && formatSubjectTokenFieldName) { + const response = await this.transporter.request( + opts + ); + subjectToken = response.data[formatSubjectTokenFieldName]; + } + if (!subjectToken) { + throw new Error( + 'Unable to parse the subject_token from the credential_source URL' + ); + } + return subjectToken; } } diff --git a/test/fixtures/external-subject-token.json b/test/fixtures/external-subject-token.json new file mode 100644 index 00000000..a47ec341 --- /dev/null +++ b/test/fixtures/external-subject-token.json @@ -0,0 +1,3 @@ +{ + "access_token": "HEADER.SIMULATED_JWT_PAYLOAD.SIGNATURE" +} \ No newline at end of file diff --git a/test/test.identitypoolclient.ts b/test/test.identitypoolclient.ts index c74f2d5e..446b792d 100644 --- a/test/test.identitypoolclient.ts +++ b/test/test.identitypoolclient.ts @@ -63,6 +63,25 @@ describe('IdentityPoolClient', () => { }, fileSourcedOptions ); + const jsonFileSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.json', + format: { + type: 'json', + subject_token_field_name: 'access_token', + }, + }, + }; + const jsonFileSourcedOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + jsonFileSourcedOptions + ); const fileSourcedOptionsNotFound = { type: 'external_account', audience, @@ -95,6 +114,26 @@ describe('IdentityPoolClient', () => { }, urlSourcedOptions ); + const jsonRespUrlSourcedOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + url: `${metadataBaseUrl}${metadataPath}`, + headers: metadataHeaders, + format: { + type: 'json', + subject_token_field_name: 'access_token', + }, + }, + }; + const jsonRespUrlSourcedOptionsWithSA = Object.assign( + { + service_account_impersonation_url: getServiceAccountImpersonationUrl(), + }, + jsonRespUrlSourcedOptions + ); const stsSuccessfulResponse: StsSuccessfulResponse = { access_token: 'ACCESS_TOKEN', issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', @@ -128,6 +167,50 @@ describe('IdentityPoolClient', () => { }, expectedError); }); + it('should throw on invalid credential_source.format.type', () => { + const expectedError = new Error('Invalid credential_source format "xml"'); + const invalidOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.txt', + format: { + type: 'xml', + }, + }, + }; + + assert.throws(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new IdentityPoolClient(invalidOptions as any); + }, expectedError); + }); + + it('should throw on required credential_source.format.subject_token_field_name', () => { + const expectedError = new Error( + 'Missing subject_token_field_name for JSON credential_source format' + ); + const invalidOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.txt', + format: { + // json formats require the key where the subject_token is located. + type: 'json', + }, + }, + }; + + assert.throws(() => { + return new IdentityPoolClient(invalidOptions); + }, expectedError); + }); + it('should not throw when valid file-sourced options are provided', () => { assert.doesNotThrow(() => { return new IdentityPoolClient(fileSourcedOptions); @@ -153,13 +236,42 @@ describe('IdentityPoolClient', () => { describe('for file-sourced subject tokens', () => { describe('retrieveSubjectToken()', () => { - it('should resolve when the file is found', async () => { + it('should resolve when the text file is found', async () => { const client = new IdentityPoolClient(fileSourcedOptions); const subjectToken = await client.retrieveSubjectToken(); assert.deepEqual(subjectToken, fileSubjectToken); }); + it('should resolve when the json file is found', async () => { + const client = new IdentityPoolClient(jsonFileSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, fileSubjectToken); + }); + + it('should reject when the json subject_token_field_name is not found', async () => { + const expectedError = new Error( + 'Unable to parse the subject_token from the credential_source file' + ); + const invalidOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + file: './test/fixtures/external-subject-token.json', + format: { + type: 'json', + subject_token_field_name: 'non-existent', + }, + }, + }; + const client = new IdentityPoolClient(invalidOptions); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + }); + it('should fail when the file is not found', async () => { const invalidFile = fileSourcedOptionsNotFound.credential_source.file; const client = new IdentityPoolClient(fileSourcedOptionsNotFound); @@ -195,7 +307,7 @@ describe('IdentityPoolClient', () => { }); describe('getAccessToken()', () => { - it('should resolve on retrieveSubjectToken success', async () => { + it('should resolve on retrieveSubjectToken success for text format', async () => { const scope = mockStsTokenExchange([ { statusCode: 200, @@ -225,7 +337,7 @@ describe('IdentityPoolClient', () => { scope.done(); }); - it('should handle service account access token', async () => { + it('should handle service account access token for text format', async () => { const now = new Date().getTime(); const saSuccessResponse = { accessToken: 'SA_ACCESS_TOKEN', @@ -271,6 +383,82 @@ describe('IdentityPoolClient', () => { scopes.forEach(scope => scope.done()); }); + it('should resolve on retrieveSubjectToken success for json format', async () => { + const scope = mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token loaded from file should be used. + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]); + + const client = new IdentityPoolClient(jsonFileSourcedOptions); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scope.done(); + }); + + it('should handle service account access token for json format', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token loaded from file should be used. + subject_token: fileSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]), + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new IdentityPoolClient(jsonFileSourcedOptionsWithSA); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: saSuccessResponse.accessToken, + }); + scopes.forEach(scope => scope.done()); + }); + it('should reject with retrieveSubjectToken error', async () => { const invalidFile = fileSourcedOptionsNotFound.credential_source.file; const client = new IdentityPoolClient(fileSourcedOptionsNotFound); @@ -287,7 +475,7 @@ describe('IdentityPoolClient', () => { describe('for url-sourced subject tokens', () => { describe('retrieveSubjectToken()', () => { - it('should resolve on success', async () => { + it('should resolve on text response success', async () => { const externalSubjectToken = 'SUBJECT_TOKEN_1'; const scope = nock(metadataBaseUrl) .get(metadataPath, undefined, { @@ -302,6 +490,57 @@ describe('IdentityPoolClient', () => { scope.done(); }); + it('should resolve on json response success', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const jsonResponse = { + access_token: externalSubjectToken, + }; + const scope = nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse); + + const client = new IdentityPoolClient(jsonRespUrlSourcedOptions); + const subjectToken = await client.retrieveSubjectToken(); + + assert.deepEqual(subjectToken, externalSubjectToken); + scope.done(); + }); + + it('should reject when the json subject_token_field_name is not found', async () => { + const expectedError = new Error( + 'Unable to parse the subject_token from the credential_source URL' + ); + const invalidOptions: IdentityPoolClientOptions = { + type: 'external_account', + audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: getTokenUrl(), + credential_source: { + url: `${metadataBaseUrl}${metadataPath}`, + headers: metadataHeaders, + format: { + type: 'json', + subject_token_field_name: 'non-existent', + }, + }, + }; + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const jsonResponse = { + access_token: externalSubjectToken, + }; + const scope = nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse); + const client = new IdentityPoolClient(invalidOptions); + + await assert.rejects(client.retrieveSubjectToken(), expectedError); + scope.done(); + }); + it('should ignore headers when not provided', async () => { // Create options without headers. const urlSourcedOptionsNoHeaders = Object.assign({}, urlSourcedOptions); @@ -337,7 +576,7 @@ describe('IdentityPoolClient', () => { }); describe('getAccessToken()', () => { - it('should resolve on retrieveSubjectToken success', async () => { + it('should resolve on retrieveSubjectToken success for text format', async () => { const externalSubjectToken = 'SUBJECT_TOKEN_1'; const scopes: nock.Scope[] = []; scopes.push( @@ -378,7 +617,7 @@ describe('IdentityPoolClient', () => { scopes.forEach(scope => scope.done()); }); - it('should handle service account access token', async () => { + it('should handle service account access token for text format', async () => { const now = new Date().getTime(); const saSuccessResponse = { accessToken: 'SA_ACCESS_TOKEN', @@ -430,6 +669,105 @@ describe('IdentityPoolClient', () => { scopes.forEach(scope => scope.done()); }); + it('should resolve on retrieveSubjectToken success for json format', async () => { + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const jsonResponse = { + access_token: externalSubjectToken, + }; + const scopes: nock.Scope[] = []; + scopes.push( + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]) + ); + scopes.push( + nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse) + ); + + const client = new IdentityPoolClient(jsonRespUrlSourcedOptions); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: stsSuccessfulResponse.access_token, + }); + scopes.forEach(scope => scope.done()); + }); + + it('should handle service account access token for json format', async () => { + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + ONE_HOUR_IN_SECS * 1000).toISOString(), + }; + const externalSubjectToken = 'SUBJECT_TOKEN_1'; + const jsonResponse = { + access_token: externalSubjectToken, + }; + const scopes: nock.Scope[] = []; + scopes.push( + nock(metadataBaseUrl) + .get(metadataPath, undefined, { + reqheaders: metadataHeaders, + }) + .reply(200, jsonResponse), + mockStsTokenExchange([ + { + statusCode: 200, + response: stsSuccessfulResponse, + request: { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + audience, + scope: 'https://www.googleapis.com/auth/cloud-platform', + requested_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + // Subject token retrieved from url should be used. + subject_token: externalSubjectToken, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }, + }, + ]), + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ]) + ); + + const client = new IdentityPoolClient(jsonRespUrlSourcedOptionsWithSA); + const actualResponse = await client.getAccessToken(); + + // Confirm raw GaxiosResponse appended to response. + assertGaxiosResponsePresent(actualResponse); + delete actualResponse.res; + assert.deepStrictEqual(actualResponse, { + token: saSuccessResponse.accessToken, + }); + scopes.forEach(scope => scope.done()); + }); + it('should reject with retrieveSubjectToken error', async () => { const scope = nock(metadataBaseUrl) .get(metadataPath, undefined, {