Skip to content

Commit

Permalink
feat: add GoogleAuth.sign() support to external account client (#1227)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bojeil-google committed Aug 10, 2021
1 parent 6799239 commit 1ca3b73
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 20 deletions.
3 changes: 2 additions & 1 deletion samples/scripts/externalclient-setup.js
Expand Up @@ -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.
Expand Down
38 changes: 35 additions & 3 deletions samples/test/externalclient.test.js
Expand Up @@ -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`,
Expand All @@ -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
Expand All @@ -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`,
Expand Down Expand Up @@ -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`,
Expand Down
12 changes: 12 additions & 0 deletions src/auth/baseexternalclient.ts
Expand Up @@ -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\/(?<email>[^:]+):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
Expand Down
31 changes: 29 additions & 2 deletions src/auth/googleauth.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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.');
Expand All @@ -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<string> {
const url =
'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/' +
`${emailOrUniqueId}:signBlob`;
const res = await this.request<SignBlobResponse>({
method: 'POST',
url,
Expand Down
2 changes: 1 addition & 1 deletion test/externalclienthelper.ts
Expand Up @@ -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`;

Expand Down
42 changes: 42 additions & 0 deletions test/test.baseexternalclient.ts
Expand Up @@ -22,6 +22,7 @@ import {StsSuccessfulResponse} from '../src/auth/stscredentials';
import {
EXPIRATION_TIME_OFFSET,
BaseExternalAccountClient,
BaseExternalAccountClientOptions,
} from '../src/auth/baseexternalclient';
import {
OAuthErrorResponse,
Expand Down Expand Up @@ -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';
Expand Down
94 changes: 81 additions & 13 deletions test/test.googleauth.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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([
{
Expand All @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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 () => {
Expand Down

0 comments on commit 1ca3b73

Please sign in to comment.