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 () => {