From 1ca3b733427d951ed624e1129fca510d84d5d0fe Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Tue, 10 Aug 2021 13:36:19 -0700 Subject: [PATCH] feat: add GoogleAuth.sign() support to external account client (#1227) * feat: add GoogleAuth.sign() support to external account client External account credentials previously did not support signing blobs. The implementation previously depended on service account keys or the service account email in order to call IAMCredentials signBlob. When service account impersonation is used with external account credentials, we can get the impersonated service account email and call the signBlob API with the generated access token, provided the token has the `iam.serviceAccounts.signBlob` permission. This is included in the "Service Account Token Creator" role. Fixes https://github.com/googleapis/google-auth-library-nodejs/issues/1215 --- samples/scripts/externalclient-setup.js | 3 +- samples/test/externalclient.test.js | 38 +++++++++- src/auth/baseexternalclient.ts | 12 ++++ src/auth/googleauth.ts | 31 +++++++- test/externalclienthelper.ts | 2 +- test/test.baseexternalclient.ts | 42 +++++++++++ test/test.googleauth.ts | 94 +++++++++++++++++++++---- 7 files changed, 202 insertions(+), 20 deletions(-) diff --git a/samples/scripts/externalclient-setup.js b/samples/scripts/externalclient-setup.js index a3db1f65..21e75954 100755 --- a/samples/scripts/externalclient-setup.js +++ b/samples/scripts/externalclient-setup.js @@ -29,7 +29,8 @@ // identity pools). // 2. Security Admin (needed to get and set IAM policies). // 3. Service Account Token Creator (needed to generate Google ID tokens and -// access tokens). +// access tokens). This is also needed to call the IAMCredentials signBlob +// API. // // The following APIs need to be enabled on the project: // 1. Identity and Access Management (IAM) API. diff --git a/samples/test/externalclient.test.js b/samples/test/externalclient.test.js index 05f699ef..5930fdb2 100644 --- a/samples/test/externalclient.test.js +++ b/samples/test/externalclient.test.js @@ -262,7 +262,7 @@ describe('samples for external-account', () => { type: 'external_account', audience: AUDIENCE_OIDC, subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - token_url: 'https://sts.googleapis.com/v1beta/token', + token_url: 'https://sts.googleapis.com/v1/token', service_account_impersonation_url: 'https://iamcredentials.googleapis.com/v1/projects/' + `-/serviceAccounts/${clientEmail}:generateAccessToken`, @@ -285,6 +285,38 @@ describe('samples for external-account', () => { assert.match(output, /DNS Info:/); }); + it('should sign the blobs with IAM credentials API', async () => { + // Create file-sourced configuration JSON file. + // The created OIDC token will be used as the subject token and will be + // retrieved from a file location. + const config = { + type: 'external_account', + audience: AUDIENCE_OIDC, + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + service_account_impersonation_url: + 'https://iamcredentials.googleapis.com/v1/projects/' + + `-/serviceAccounts/${clientEmail}:generateAccessToken`, + credential_source: { + file: oidcTokenFilePath, + }, + }; + await writeFile(oidcTokenFilePath, oidcToken); + await writeFile(configFilePath, JSON.stringify(config)); + + // Run sample script with GOOGLE_APPLICATION_CREDENTIALS envvar + // pointing to the temporarily created configuration file. + // This script will use signBlob to sign some data using + // service account impersonated workload identity pool credentials. + const output = await execAsync(`${process.execPath} signBlob`, { + env: { + ...process.env, + GOOGLE_APPLICATION_CREDENTIALS: configFilePath, + }, + }); + assert.ok(output.length > 0); + }); + it('should acquire ADC for url-sourced creds', async () => { // Create url-sourced configuration JSON file. // The created OIDC token will be used as the subject token and will be @@ -293,7 +325,7 @@ describe('samples for external-account', () => { type: 'external_account', audience: AUDIENCE_OIDC, subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', - token_url: 'https://sts.googleapis.com/v1beta/token', + token_url: 'https://sts.googleapis.com/v1/token', service_account_impersonation_url: 'https://iamcredentials.googleapis.com/v1/projects/' + `-/serviceAccounts/${clientEmail}:generateAccessToken`, @@ -358,7 +390,7 @@ describe('samples for external-account', () => { type: 'external_account', audience: AUDIENCE_AWS, subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request', - token_url: 'https://sts.googleapis.com/v1beta/token', + token_url: 'https://sts.googleapis.com/v1/token', service_account_impersonation_url: 'https://iamcredentials.googleapis.com/v1/projects/' + `-/serviceAccounts/${clientEmail}:generateAccessToken`, diff --git a/src/auth/baseexternalclient.ts b/src/auth/baseexternalclient.ts index ca2ca7e6..97db8c64 100644 --- a/src/auth/baseexternalclient.ts +++ b/src/auth/baseexternalclient.ts @@ -177,6 +177,18 @@ export abstract class BaseExternalAccountClient extends AuthClient { this.projectNumber = this.getProjectNumber(this.audience); } + /** The service account email to be impersonated, if available. */ + getServiceAccountEmail(): string | null { + if (this.serviceAccountImpersonationUrl) { + // Parse email from URL. The formal looks as follows: + // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken + const re = /serviceAccounts\/(?[^:]+):generateAccessToken$/; + const result = re.exec(this.serviceAccountImpersonationUrl); + return result?.groups?.email || null; + } + return null; + } + /** * Provides a mechanism to inject GCP access tokens directly. * When the provided credential expires, a new credential, using the diff --git a/src/auth/googleauth.ts b/src/auth/googleauth.ts index 1b037173..6675b7ba 100644 --- a/src/auth/googleauth.ts +++ b/src/auth/googleauth.ts @@ -20,7 +20,7 @@ import * as os from 'os'; import * as path from 'path'; import * as stream from 'stream'; -import {createCrypto} from '../crypto/crypto'; +import {Crypto, createCrypto} from '../crypto/crypto'; import {DefaultTransporter, Transporter} from '../transporters'; import {Compute, ComputeOptions} from './computeclient'; @@ -877,6 +877,23 @@ export class GoogleAuth { return sign; } + // signBlob requires a service account email and the underlying + // access token to have iam.serviceAccounts.signBlob permission + // on the specified resource name. + // The "Service Account Token Creator" role should cover this. + // As a result external account credentials can support this + // operation when service account impersonation is enabled. + if ( + client instanceof BaseExternalAccountClient && + client.getServiceAccountEmail() + ) { + return this.signBlob( + crypto, + client.getServiceAccountEmail() as string, + data + ); + } + const projectId = await this.getProjectId(); if (!projectId) { throw new Error('Cannot sign data without a project ID.'); @@ -887,7 +904,17 @@ export class GoogleAuth { throw new Error('Cannot sign data without `client_email`.'); } - const url = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${creds.client_email}:signBlob`; + return this.signBlob(crypto, creds.client_email, data); + } + + private async signBlob( + crypto: Crypto, + emailOrUniqueId: string, + data: string + ): Promise { + const url = + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' + + `${emailOrUniqueId}:signBlob`; const res = await this.request({ method: 'POST', url, diff --git a/test/externalclienthelper.ts b/test/externalclienthelper.ts index 391616e2..9ab9d4d1 100644 --- a/test/externalclienthelper.ts +++ b/test/externalclienthelper.ts @@ -51,7 +51,7 @@ const poolId = 'POOL_ID'; const providerId = 'PROVIDER_ID'; const baseUrl = 'https://sts.googleapis.com'; const path = '/v1/token'; -const saEmail = 'service-1234@service-name.iam.gserviceaccount.com'; +export const saEmail = 'service-1234@service-name.iam.gserviceaccount.com'; const saBaseUrl = 'https://iamcredentials.googleapis.com'; const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`; diff --git a/test/test.baseexternalclient.ts b/test/test.baseexternalclient.ts index 60599031..e6baf436 100644 --- a/test/test.baseexternalclient.ts +++ b/test/test.baseexternalclient.ts @@ -22,6 +22,7 @@ import {StsSuccessfulResponse} from '../src/auth/stscredentials'; import { EXPIRATION_TIME_OFFSET, BaseExternalAccountClient, + BaseExternalAccountClientOptions, } from '../src/auth/baseexternalclient'; import { OAuthErrorResponse, @@ -190,6 +191,47 @@ describe('BaseExternalAccountClient', () => { }); }); + describe('getServiceAccountEmail()', () => { + it('should return the service account email when impersonation is used', () => { + const saEmail = 'service-1234@service-name.iam.gserviceaccount.com'; + const saBaseUrl = 'https://iamcredentials.googleapis.com'; + const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`; + const options: BaseExternalAccountClientOptions = Object.assign( + {}, + externalAccountOptions + ); + options.service_account_impersonation_url = `${saBaseUrl}${saPath}`; + const client = new TestExternalAccountClient(options); + + assert.strictEqual(client.getServiceAccountEmail(), saEmail); + }); + + it('should return null when impersonation is not used', () => { + const options: BaseExternalAccountClientOptions = Object.assign( + {}, + externalAccountOptions + ); + delete options.service_account_impersonation_url; + const client = new TestExternalAccountClient(options); + + assert(client.getServiceAccountEmail() === null); + }); + + it('should return null when impersonation url is malformed', () => { + const saBaseUrl = 'https://iamcredentials.googleapis.com'; + // Malformed path (missing the service account email). + const saPath = '/v1/projects/-/serviceAccounts/:generateAccessToken'; + const options: BaseExternalAccountClientOptions = Object.assign( + {}, + externalAccountOptions + ); + options.service_account_impersonation_url = `${saBaseUrl}${saPath}`; + const client = new TestExternalAccountClient(options); + + assert(client.getServiceAccountEmail() === null); + }); + }); + describe('getProjectId()', () => { it('should resolve with projectId when determinable', async () => { const projectNumber = 'my-proj-number'; diff --git a/test/test.googleauth.ts b/test/test.googleauth.ts index a08a98af..f601c25a 100644 --- a/test/test.googleauth.ts +++ b/test/test.googleauth.ts @@ -44,8 +44,11 @@ import {CredentialBody} from '../src/auth/credentials'; import * as envDetect from '../src/auth/envDetect'; import {Compute} from '../src/auth/computeclient'; import { + getServiceAccountImpersonationUrl, mockCloudResourceManager, + mockGenerateAccessToken, mockStsTokenExchange, + saEmail, } from './externalclienthelper'; import {BaseExternalAccountClient} from '../src/auth/baseexternalclient'; import {AuthClient} from '../src/auth/authclient'; @@ -1596,6 +1599,11 @@ describe('googleauth', () => { expires_in: 3600, scope: 'scope1 scope2', }; + const now = new Date().getTime(); + const saSuccessResponse = { + accessToken: 'SA_ACCESS_TOKEN', + expireTime: new Date(now + 3600 * 1000).toISOString(), + }; const fileSubjectToken = fs.readFileSync( externalAccountJSON.credential_source.file, 'utf-8' @@ -1640,13 +1648,19 @@ describe('googleauth', () => { * manager. * @param mockProjectIdRetrieval Whether to mock project ID retrieval. * @param expectedScopes The list of expected scopes. + * @param mockServiceAccountImpersonation Whether to mock IAMCredentials + * GenerateAccessToken. * @return The list of nock.Scope corresponding to the mocked HTTP * requests. */ function mockGetAccessTokenAndProjectId( mockProjectIdRetrieval = true, - expectedScopes = ['https://www.googleapis.com/auth/cloud-platform'] + expectedScopes = ['https://www.googleapis.com/auth/cloud-platform'], + mockServiceAccountImpersonation = false ): nock.Scope[] { + const stsScopes = mockServiceAccountImpersonation + ? 'https://www.googleapis.com/auth/cloud-platform' + : expectedScopes.join(' '); const scopes = [ mockStsTokenExchange([ { @@ -1655,7 +1669,7 @@ describe('googleauth', () => { request: { grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', audience: externalAccountJSON.audience, - scope: expectedScopes.join(' '), + scope: stsScopes, requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', subject_token: fileSubjectToken, @@ -1664,6 +1678,18 @@ describe('googleauth', () => { }, ]), ]; + if (mockServiceAccountImpersonation) { + scopes.push( + mockGenerateAccessToken([ + { + statusCode: 200, + response: saSuccessResponse, + token: stsSuccessfulResponse.access_token, + scopes: expectedScopes, + }, + ]) + ); + } if (mockProjectIdRetrieval) { scopes.push( @@ -2163,24 +2189,66 @@ describe('googleauth', () => { }); }); - it('getIdTokenClient() should reject', async () => { - const auth = new GoogleAuth({credentials: createExternalAccountJSON()}); + describe('sign()', () => { + it('should reject when no impersonation is used', async () => { + const scopes = mockGetAccessTokenAndProjectId(); + const auth = new GoogleAuth({ + credentials: createExternalAccountJSON(), + }); - await assert.rejects( - auth.getIdTokenClient('a-target-audience'), - /Cannot fetch ID token in this environment/ - ); + await assert.rejects( + auth.sign('abc123'), + /Cannot sign data without `client_email`/ + ); + scopes.forEach(s => s.done()); + }); + + it('should use IAMCredentials endpoint when impersonation is used', async () => { + const scopes = mockGetAccessTokenAndProjectId( + false, + ['https://www.googleapis.com/auth/cloud-platform'], + true + ); + const email = saEmail; + const configWithImpersonation = createExternalAccountJSON(); + configWithImpersonation.service_account_impersonation_url = + getServiceAccountImpersonationUrl(); + const iamUri = 'https://iamcredentials.googleapis.com'; + const iamPath = `/v1/projects/-/serviceAccounts/${email}:signBlob`; + const signedBlob = 'erutangis'; + const data = 'abc123'; + scopes.push( + nock(iamUri) + .post( + iamPath, + { + payload: Buffer.from(data, 'utf-8').toString('base64'), + }, + { + reqheaders: { + Authorization: `Bearer ${saSuccessResponse.accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + .reply(200, {signedBlob}) + ); + const auth = new GoogleAuth({credentials: configWithImpersonation}); + + const value = await auth.sign(data); + + scopes.forEach(x => x.done()); + assert.strictEqual(value, signedBlob); + }); }); - it('sign() should reject', async () => { - const scopes = mockGetAccessTokenAndProjectId(); + it('getIdTokenClient() should reject', async () => { const auth = new GoogleAuth({credentials: createExternalAccountJSON()}); await assert.rejects( - auth.sign('abc123'), - /Cannot sign data without `client_email`/ + auth.getIdTokenClient('a-target-audience'), + /Cannot fetch ID token in this environment/ ); - scopes.forEach(s => s.done()); }); it('getAccessToken() should get an access token', async () => {