Skip to content

Commit 1ca3b73

Browse files
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 #1215
1 parent 6799239 commit 1ca3b73

File tree

7 files changed

+202
-20
lines changed

7 files changed

+202
-20
lines changed

samples/scripts/externalclient-setup.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
// identity pools).
3030
// 2. Security Admin (needed to get and set IAM policies).
3131
// 3. Service Account Token Creator (needed to generate Google ID tokens and
32-
// access tokens).
32+
// access tokens). This is also needed to call the IAMCredentials signBlob
33+
// API.
3334
//
3435
// The following APIs need to be enabled on the project:
3536
// 1. Identity and Access Management (IAM) API.

samples/test/externalclient.test.js

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ describe('samples for external-account', () => {
262262
type: 'external_account',
263263
audience: AUDIENCE_OIDC,
264264
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
265-
token_url: 'https://sts.googleapis.com/v1beta/token',
265+
token_url: 'https://sts.googleapis.com/v1/token',
266266
service_account_impersonation_url:
267267
'https://iamcredentials.googleapis.com/v1/projects/' +
268268
`-/serviceAccounts/${clientEmail}:generateAccessToken`,
@@ -285,6 +285,38 @@ describe('samples for external-account', () => {
285285
assert.match(output, /DNS Info:/);
286286
});
287287

288+
it('should sign the blobs with IAM credentials API', async () => {
289+
// Create file-sourced configuration JSON file.
290+
// The created OIDC token will be used as the subject token and will be
291+
// retrieved from a file location.
292+
const config = {
293+
type: 'external_account',
294+
audience: AUDIENCE_OIDC,
295+
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
296+
token_url: 'https://sts.googleapis.com/v1/token',
297+
service_account_impersonation_url:
298+
'https://iamcredentials.googleapis.com/v1/projects/' +
299+
`-/serviceAccounts/${clientEmail}:generateAccessToken`,
300+
credential_source: {
301+
file: oidcTokenFilePath,
302+
},
303+
};
304+
await writeFile(oidcTokenFilePath, oidcToken);
305+
await writeFile(configFilePath, JSON.stringify(config));
306+
307+
// Run sample script with GOOGLE_APPLICATION_CREDENTIALS envvar
308+
// pointing to the temporarily created configuration file.
309+
// This script will use signBlob to sign some data using
310+
// service account impersonated workload identity pool credentials.
311+
const output = await execAsync(`${process.execPath} signBlob`, {
312+
env: {
313+
...process.env,
314+
GOOGLE_APPLICATION_CREDENTIALS: configFilePath,
315+
},
316+
});
317+
assert.ok(output.length > 0);
318+
});
319+
288320
it('should acquire ADC for url-sourced creds', async () => {
289321
// Create url-sourced configuration JSON file.
290322
// The created OIDC token will be used as the subject token and will be
@@ -293,7 +325,7 @@ describe('samples for external-account', () => {
293325
type: 'external_account',
294326
audience: AUDIENCE_OIDC,
295327
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
296-
token_url: 'https://sts.googleapis.com/v1beta/token',
328+
token_url: 'https://sts.googleapis.com/v1/token',
297329
service_account_impersonation_url:
298330
'https://iamcredentials.googleapis.com/v1/projects/' +
299331
`-/serviceAccounts/${clientEmail}:generateAccessToken`,
@@ -358,7 +390,7 @@ describe('samples for external-account', () => {
358390
type: 'external_account',
359391
audience: AUDIENCE_AWS,
360392
subject_token_type: 'urn:ietf:params:aws:token-type:aws4_request',
361-
token_url: 'https://sts.googleapis.com/v1beta/token',
393+
token_url: 'https://sts.googleapis.com/v1/token',
362394
service_account_impersonation_url:
363395
'https://iamcredentials.googleapis.com/v1/projects/' +
364396
`-/serviceAccounts/${clientEmail}:generateAccessToken`,

src/auth/baseexternalclient.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,18 @@ export abstract class BaseExternalAccountClient extends AuthClient {
177177
this.projectNumber = this.getProjectNumber(this.audience);
178178
}
179179

180+
/** The service account email to be impersonated, if available. */
181+
getServiceAccountEmail(): string | null {
182+
if (this.serviceAccountImpersonationUrl) {
183+
// Parse email from URL. The formal looks as follows:
184+
// https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
185+
const re = /serviceAccounts\/(?<email>[^:]+):generateAccessToken$/;
186+
const result = re.exec(this.serviceAccountImpersonationUrl);
187+
return result?.groups?.email || null;
188+
}
189+
return null;
190+
}
191+
180192
/**
181193
* Provides a mechanism to inject GCP access tokens directly.
182194
* When the provided credential expires, a new credential, using the

src/auth/googleauth.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import * as os from 'os';
2020
import * as path from 'path';
2121
import * as stream from 'stream';
2222

23-
import {createCrypto} from '../crypto/crypto';
23+
import {Crypto, createCrypto} from '../crypto/crypto';
2424
import {DefaultTransporter, Transporter} from '../transporters';
2525

2626
import {Compute, ComputeOptions} from './computeclient';
@@ -877,6 +877,23 @@ export class GoogleAuth {
877877
return sign;
878878
}
879879

880+
// signBlob requires a service account email and the underlying
881+
// access token to have iam.serviceAccounts.signBlob permission
882+
// on the specified resource name.
883+
// The "Service Account Token Creator" role should cover this.
884+
// As a result external account credentials can support this
885+
// operation when service account impersonation is enabled.
886+
if (
887+
client instanceof BaseExternalAccountClient &&
888+
client.getServiceAccountEmail()
889+
) {
890+
return this.signBlob(
891+
crypto,
892+
client.getServiceAccountEmail() as string,
893+
data
894+
);
895+
}
896+
880897
const projectId = await this.getProjectId();
881898
if (!projectId) {
882899
throw new Error('Cannot sign data without a project ID.');
@@ -887,7 +904,17 @@ export class GoogleAuth {
887904
throw new Error('Cannot sign data without `client_email`.');
888905
}
889906

890-
const url = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${creds.client_email}:signBlob`;
907+
return this.signBlob(crypto, creds.client_email, data);
908+
}
909+
910+
private async signBlob(
911+
crypto: Crypto,
912+
emailOrUniqueId: string,
913+
data: string
914+
): Promise<string> {
915+
const url =
916+
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' +
917+
`${emailOrUniqueId}:signBlob`;
891918
const res = await this.request<SignBlobResponse>({
892919
method: 'POST',
893920
url,

test/externalclienthelper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const poolId = 'POOL_ID';
5151
const providerId = 'PROVIDER_ID';
5252
const baseUrl = 'https://sts.googleapis.com';
5353
const path = '/v1/token';
54-
const saEmail = 'service-1234@service-name.iam.gserviceaccount.com';
54+
export const saEmail = 'service-1234@service-name.iam.gserviceaccount.com';
5555
const saBaseUrl = 'https://iamcredentials.googleapis.com';
5656
const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`;
5757

test/test.baseexternalclient.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {StsSuccessfulResponse} from '../src/auth/stscredentials';
2222
import {
2323
EXPIRATION_TIME_OFFSET,
2424
BaseExternalAccountClient,
25+
BaseExternalAccountClientOptions,
2526
} from '../src/auth/baseexternalclient';
2627
import {
2728
OAuthErrorResponse,
@@ -190,6 +191,47 @@ describe('BaseExternalAccountClient', () => {
190191
});
191192
});
192193

194+
describe('getServiceAccountEmail()', () => {
195+
it('should return the service account email when impersonation is used', () => {
196+
const saEmail = 'service-1234@service-name.iam.gserviceaccount.com';
197+
const saBaseUrl = 'https://iamcredentials.googleapis.com';
198+
const saPath = `/v1/projects/-/serviceAccounts/${saEmail}:generateAccessToken`;
199+
const options: BaseExternalAccountClientOptions = Object.assign(
200+
{},
201+
externalAccountOptions
202+
);
203+
options.service_account_impersonation_url = `${saBaseUrl}${saPath}`;
204+
const client = new TestExternalAccountClient(options);
205+
206+
assert.strictEqual(client.getServiceAccountEmail(), saEmail);
207+
});
208+
209+
it('should return null when impersonation is not used', () => {
210+
const options: BaseExternalAccountClientOptions = Object.assign(
211+
{},
212+
externalAccountOptions
213+
);
214+
delete options.service_account_impersonation_url;
215+
const client = new TestExternalAccountClient(options);
216+
217+
assert(client.getServiceAccountEmail() === null);
218+
});
219+
220+
it('should return null when impersonation url is malformed', () => {
221+
const saBaseUrl = 'https://iamcredentials.googleapis.com';
222+
// Malformed path (missing the service account email).
223+
const saPath = '/v1/projects/-/serviceAccounts/:generateAccessToken';
224+
const options: BaseExternalAccountClientOptions = Object.assign(
225+
{},
226+
externalAccountOptions
227+
);
228+
options.service_account_impersonation_url = `${saBaseUrl}${saPath}`;
229+
const client = new TestExternalAccountClient(options);
230+
231+
assert(client.getServiceAccountEmail() === null);
232+
});
233+
});
234+
193235
describe('getProjectId()', () => {
194236
it('should resolve with projectId when determinable', async () => {
195237
const projectNumber = 'my-proj-number';

test/test.googleauth.ts

Lines changed: 81 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ import {CredentialBody} from '../src/auth/credentials';
4444
import * as envDetect from '../src/auth/envDetect';
4545
import {Compute} from '../src/auth/computeclient';
4646
import {
47+
getServiceAccountImpersonationUrl,
4748
mockCloudResourceManager,
49+
mockGenerateAccessToken,
4850
mockStsTokenExchange,
51+
saEmail,
4952
} from './externalclienthelper';
5053
import {BaseExternalAccountClient} from '../src/auth/baseexternalclient';
5154
import {AuthClient} from '../src/auth/authclient';
@@ -1596,6 +1599,11 @@ describe('googleauth', () => {
15961599
expires_in: 3600,
15971600
scope: 'scope1 scope2',
15981601
};
1602+
const now = new Date().getTime();
1603+
const saSuccessResponse = {
1604+
accessToken: 'SA_ACCESS_TOKEN',
1605+
expireTime: new Date(now + 3600 * 1000).toISOString(),
1606+
};
15991607
const fileSubjectToken = fs.readFileSync(
16001608
externalAccountJSON.credential_source.file,
16011609
'utf-8'
@@ -1640,13 +1648,19 @@ describe('googleauth', () => {
16401648
* manager.
16411649
* @param mockProjectIdRetrieval Whether to mock project ID retrieval.
16421650
* @param expectedScopes The list of expected scopes.
1651+
* @param mockServiceAccountImpersonation Whether to mock IAMCredentials
1652+
* GenerateAccessToken.
16431653
* @return The list of nock.Scope corresponding to the mocked HTTP
16441654
* requests.
16451655
*/
16461656
function mockGetAccessTokenAndProjectId(
16471657
mockProjectIdRetrieval = true,
1648-
expectedScopes = ['https://www.googleapis.com/auth/cloud-platform']
1658+
expectedScopes = ['https://www.googleapis.com/auth/cloud-platform'],
1659+
mockServiceAccountImpersonation = false
16491660
): nock.Scope[] {
1661+
const stsScopes = mockServiceAccountImpersonation
1662+
? 'https://www.googleapis.com/auth/cloud-platform'
1663+
: expectedScopes.join(' ');
16501664
const scopes = [
16511665
mockStsTokenExchange([
16521666
{
@@ -1655,7 +1669,7 @@ describe('googleauth', () => {
16551669
request: {
16561670
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
16571671
audience: externalAccountJSON.audience,
1658-
scope: expectedScopes.join(' '),
1672+
scope: stsScopes,
16591673
requested_token_type:
16601674
'urn:ietf:params:oauth:token-type:access_token',
16611675
subject_token: fileSubjectToken,
@@ -1664,6 +1678,18 @@ describe('googleauth', () => {
16641678
},
16651679
]),
16661680
];
1681+
if (mockServiceAccountImpersonation) {
1682+
scopes.push(
1683+
mockGenerateAccessToken([
1684+
{
1685+
statusCode: 200,
1686+
response: saSuccessResponse,
1687+
token: stsSuccessfulResponse.access_token,
1688+
scopes: expectedScopes,
1689+
},
1690+
])
1691+
);
1692+
}
16671693

16681694
if (mockProjectIdRetrieval) {
16691695
scopes.push(
@@ -2163,24 +2189,66 @@ describe('googleauth', () => {
21632189
});
21642190
});
21652191

2166-
it('getIdTokenClient() should reject', async () => {
2167-
const auth = new GoogleAuth({credentials: createExternalAccountJSON()});
2192+
describe('sign()', () => {
2193+
it('should reject when no impersonation is used', async () => {
2194+
const scopes = mockGetAccessTokenAndProjectId();
2195+
const auth = new GoogleAuth({
2196+
credentials: createExternalAccountJSON(),
2197+
});
21682198

2169-
await assert.rejects(
2170-
auth.getIdTokenClient('a-target-audience'),
2171-
/Cannot fetch ID token in this environment/
2172-
);
2199+
await assert.rejects(
2200+
auth.sign('abc123'),
2201+
/Cannot sign data without `client_email`/
2202+
);
2203+
scopes.forEach(s => s.done());
2204+
});
2205+
2206+
it('should use IAMCredentials endpoint when impersonation is used', async () => {
2207+
const scopes = mockGetAccessTokenAndProjectId(
2208+
false,
2209+
['https://www.googleapis.com/auth/cloud-platform'],
2210+
true
2211+
);
2212+
const email = saEmail;
2213+
const configWithImpersonation = createExternalAccountJSON();
2214+
configWithImpersonation.service_account_impersonation_url =
2215+
getServiceAccountImpersonationUrl();
2216+
const iamUri = 'https://iamcredentials.googleapis.com';
2217+
const iamPath = `/v1/projects/-/serviceAccounts/${email}:signBlob`;
2218+
const signedBlob = 'erutangis';
2219+
const data = 'abc123';
2220+
scopes.push(
2221+
nock(iamUri)
2222+
.post(
2223+
iamPath,
2224+
{
2225+
payload: Buffer.from(data, 'utf-8').toString('base64'),
2226+
},
2227+
{
2228+
reqheaders: {
2229+
Authorization: `Bearer ${saSuccessResponse.accessToken}`,
2230+
'Content-Type': 'application/json',
2231+
},
2232+
}
2233+
)
2234+
.reply(200, {signedBlob})
2235+
);
2236+
const auth = new GoogleAuth({credentials: configWithImpersonation});
2237+
2238+
const value = await auth.sign(data);
2239+
2240+
scopes.forEach(x => x.done());
2241+
assert.strictEqual(value, signedBlob);
2242+
});
21732243
});
21742244

2175-
it('sign() should reject', async () => {
2176-
const scopes = mockGetAccessTokenAndProjectId();
2245+
it('getIdTokenClient() should reject', async () => {
21772246
const auth = new GoogleAuth({credentials: createExternalAccountJSON()});
21782247

21792248
await assert.rejects(
2180-
auth.sign('abc123'),
2181-
/Cannot sign data without `client_email`/
2249+
auth.getIdTokenClient('a-target-audience'),
2250+
/Cannot fetch ID token in this environment/
21822251
);
2183-
scopes.forEach(s => s.done());
21842252
});
21852253

21862254
it('getAccessToken() should get an access token', async () => {

0 commit comments

Comments
 (0)